Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67a1a01b4e | ||
|
|
152e4259ae | ||
|
|
5c30fd4fb7 | ||
|
|
3f89511bdd | ||
|
|
3edcb10310 | ||
|
|
df553cc70d | ||
|
|
f32ff08365 | ||
|
|
c44a9b174f | ||
|
|
f1030aae82 | ||
|
|
dc97afb0ef | ||
|
|
eb4c2ff13d | ||
|
|
4463508f29 | ||
|
|
11ca4a5902 |
+27
-80
@@ -5,22 +5,18 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: forgejo.riotpiao.com
|
REGISTRY: forgejo.riotpiao.com
|
||||||
IMAGE: forgejo.riotpiao.com/rock/api-gateway
|
IMAGE: forgejo.riotpiao.com/rock/api-gateway
|
||||||
DOCKER_HOST: tcp://localhost:2375
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
test:
|
||||||
name: CI
|
name: Test
|
||||||
runs-on: golang
|
runs-on: golang
|
||||||
steps:
|
steps:
|
||||||
- name: Install Node.js and Docker
|
- name: Install Node.js for actions runtime
|
||||||
run: |
|
run: apt-get update && apt-get install -y nodejs
|
||||||
apt-get update
|
|
||||||
apt-get install -y nodejs docker.io
|
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -31,9 +27,25 @@ jobs:
|
|||||||
- name: Go test
|
- name: Go test
|
||||||
run: go test ./...
|
run: go test ./...
|
||||||
|
|
||||||
|
build-push:
|
||||||
|
name: Build & Push Image
|
||||||
|
needs: test
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
runs-on: golang
|
||||||
|
steps:
|
||||||
|
- name: Install Node.js and Docker
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y nodejs docker.io
|
||||||
|
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Get short SHA
|
- name: Get short SHA
|
||||||
id: sha
|
id: sha
|
||||||
run: echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
run: |
|
||||||
|
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
|
echo "short_sha=${SHORT_SHA}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Registry login
|
- name: Registry login
|
||||||
run: |
|
run: |
|
||||||
@@ -47,80 +59,15 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker build --no-cache \
|
docker build --no-cache \
|
||||||
-t "${IMAGE}:${{ steps.sha.outputs.short_sha }}" \
|
-t "${IMAGE}:${{ steps.sha.outputs.short_sha }}" \
|
||||||
-f Dockerfile .
|
-t "${IMAGE}:latest" \
|
||||||
echo "Built image: ${IMAGE}:${{ steps.sha.outputs.short_sha }}"
|
-f Dockerfile \
|
||||||
|
.
|
||||||
|
|
||||||
- name: Push test image (SHA tag only, not latest yet)
|
- name: Push Docker image
|
||||||
run: |
|
run: |
|
||||||
docker push "${IMAGE}:${{ steps.sha.outputs.short_sha }}"
|
docker push "${IMAGE}:${{ steps.sha.outputs.short_sha }}"
|
||||||
echo "✓ Pushed test image: ${IMAGE}:${{ steps.sha.outputs.short_sha }}"
|
|
||||||
|
|
||||||
- name: Detect in-cluster Kubernetes authentication
|
|
||||||
run: |
|
|
||||||
# When running inside K8s cluster, kubectl auto-detects service account
|
|
||||||
# Mounted at: /var/run/secrets/kubernetes.io/serviceaccount/
|
|
||||||
if [ -f /var/run/secrets/kubernetes.io/serviceaccount/token ]; then
|
|
||||||
echo "✓ In-cluster authentication detected"
|
|
||||||
export KUBECONFIG=/dev/null # kubectl will auto-use in-cluster auth
|
|
||||||
else
|
|
||||||
echo "⚠ Not running in-cluster, kubectl may fail"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Run integration tests via Kubernetes Job
|
|
||||||
run: |
|
|
||||||
echo "Running integration tests via Kubernetes Job..."
|
|
||||||
echo "Test image: ${IMAGE}:${{ steps.sha.outputs.short_sha }}"
|
|
||||||
|
|
||||||
# Apply job template from repo (uses in-cluster auth automatically)
|
|
||||||
kubectl apply -f k8s/integration-test-job.yaml
|
|
||||||
|
|
||||||
# Update job to use new image
|
|
||||||
kubectl set image job/api-gateway-integration-test \
|
|
||||||
integration-tester="${IMAGE}:${{ steps.sha.outputs.short_sha }}" \
|
|
||||||
-n api --record
|
|
||||||
|
|
||||||
# Wait for job to complete (max 10 minutes)
|
|
||||||
echo "Waiting for job to complete (this may take a few minutes)..."
|
|
||||||
kubectl wait --for=condition=complete job/api-gateway-integration-test \
|
|
||||||
-n api --timeout=10m 2>/dev/null || true
|
|
||||||
|
|
||||||
# Stream logs
|
|
||||||
echo ""
|
|
||||||
echo "=== Job Logs ==="
|
|
||||||
kubectl logs -n api job/api-gateway-integration-test --all-containers=true --timestamps=true || echo "No logs available"
|
|
||||||
echo "================"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Check if job succeeded
|
|
||||||
SUCCEEDED=$(kubectl get job api-gateway-integration-test -n api -o jsonpath='{.status.succeeded}' 2>/dev/null || echo "0")
|
|
||||||
FAILED=$(kubectl get job api-gateway-integration-test -n api -o jsonpath='{.status.failed}' 2>/dev/null || echo "0")
|
|
||||||
|
|
||||||
echo "Job Status: Succeeded=$SUCCEEDED, Failed=$FAILED"
|
|
||||||
|
|
||||||
if [ "$SUCCEEDED" = "1" ]; then
|
|
||||||
echo "✓ Integration tests PASSED"
|
|
||||||
exit 0
|
|
||||||
else
|
|
||||||
echo "✗ Integration tests FAILED"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
continue-on-error: false
|
|
||||||
|
|
||||||
- name: Promote image to latest (only if tests passed)
|
|
||||||
if: success()
|
|
||||||
run: |
|
|
||||||
docker pull "${IMAGE}:${{ steps.sha.outputs.short_sha }}"
|
|
||||||
docker tag "${IMAGE}:${{ steps.sha.outputs.short_sha }}" "${IMAGE}:latest"
|
|
||||||
docker push "${IMAGE}:latest"
|
docker push "${IMAGE}:latest"
|
||||||
echo "✓ Promoted ${IMAGE}:${{ steps.sha.outputs.short_sha }} to latest"
|
echo "✓ Image pushed: ${IMAGE}:${{ steps.sha.outputs.short_sha }}"
|
||||||
|
|
||||||
- name: Cleanup integration test job
|
- name: Prune unused images
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
echo "Cleaning up test job..."
|
|
||||||
kubectl delete job api-gateway-integration-test -n api --ignore-not-found=true
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
- name: Cleanup docker
|
|
||||||
if: always()
|
|
||||||
run: docker image prune -a --force 2>&1 | tail -3 || true
|
run: docker image prune -a --force 2>&1 | tail -3 || true
|
||||||
|
|||||||
+2
-33
@@ -1,34 +1,3 @@
|
|||||||
# SOPS Configuration for secrets encryption
|
|
||||||
# Public keys are safe to commit; private keys stay in cluster
|
|
||||||
|
|
||||||
creation_rules:
|
creation_rules:
|
||||||
# Encrypt secrets, configs, and sensitive files
|
- path_regex: \.enc\.ya?ml$
|
||||||
# Multiple public keys for key rotation support
|
age: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
|
||||||
# Files matching these patterns will be encrypted automatically with `sops -e`
|
|
||||||
- path_regex: k8s/(.*secret.*|.*config.*|.*deployment.*\.ya?ml)
|
|
||||||
age:
|
|
||||||
- age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
|
|
||||||
- age1ryxmuwhecmdru786eqgek4cf8ppq585j2uqr7e87phya42w9s5wscn6tgp
|
|
||||||
encrypted_regex: '^data|^stringData' # Only encrypt data fields, keep structure readable
|
|
||||||
|
|
||||||
# Fallback rule for .enc.yaml files
|
|
||||||
- path_regex: '.*\.enc\.ya?ml'
|
|
||||||
age:
|
|
||||||
- age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
|
|
||||||
- age1ryxmuwhecmdru786eqgek4cf8ppq585j2uqr7e87phya42w9s5wscn6tgp
|
|
||||||
encrypted_regex: '^data|^stringData'
|
|
||||||
|
|
||||||
# To encrypt a file locally:
|
|
||||||
# sops --encrypt k8s/configmap.yaml > k8s/configmap.yaml
|
|
||||||
#
|
|
||||||
# To decrypt and view:
|
|
||||||
# sops k8s/configmap.yaml
|
|
||||||
#
|
|
||||||
# To decrypt to stdout:
|
|
||||||
# sops --decrypt k8s/configmap.yaml
|
|
||||||
#
|
|
||||||
# The private age keys are stored in the cluster at:
|
|
||||||
# kubectl -n argocd get secret sops-age -o jsonpath='{.data.key\.txt}' | base64 -d
|
|
||||||
#
|
|
||||||
# Key rotation: Multiple public keys can coexist for decryption
|
|
||||||
# Only private keys MUST be kept secret (in cluster only)
|
|
||||||
|
|||||||
+1
-8
@@ -9,7 +9,6 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
|
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/proxy"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/proxy"
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/server"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/server"
|
||||||
@@ -76,13 +75,7 @@ func main() {
|
|||||||
_ = registry.Add(a)
|
_ = registry.Add(a)
|
||||||
}
|
}
|
||||||
log.Printf("%d service adapters loaded", registry.Count())
|
log.Printf("%d service adapters loaded", registry.Count())
|
||||||
|
dispatcher := serviceadapter.NewDispatcher(registry)
|
||||||
// Create shared JWT validator for X-Service auth enforcement
|
|
||||||
var jwtValidator *auth.Validator
|
|
||||||
if cfg.Auth.Enabled && cfg.Auth.JWKSURL != "" {
|
|
||||||
jwtValidator = auth.NewValidator(cfg.Auth.Issuer, cfg.Auth.Audience, cfg.Auth.JWKSURL)
|
|
||||||
}
|
|
||||||
dispatcher := serviceadapter.NewDispatcher(registry, jwtValidator)
|
|
||||||
|
|
||||||
// Create router that handles health endpoints, X-Service (ServiceAdapter) routing,
|
// Create router that handles health endpoints, X-Service (ServiceAdapter) routing,
|
||||||
// temporal endpoints, and passes others to upstream handler
|
// temporal endpoints, and passes others to upstream handler
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
package config_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestLoadAuthConfig_TokenURLAndClientID(t *testing.T) {
|
|
||||||
yaml := `
|
|
||||||
routes: []
|
|
||||||
models: []
|
|
||||||
auth:
|
|
||||||
enabled: true
|
|
||||||
issuer: "https://authentik.example.com/application/o/api-gw/"
|
|
||||||
audience: "api-gw"
|
|
||||||
jwksUrl: "https://authentik.example.com/application/o/api-gw/jwks/"
|
|
||||||
requiredCapability: "llm:inference"
|
|
||||||
tokenUrl: "https://authentik.example.com/application/o/token/"
|
|
||||||
clientId: "api-gw"
|
|
||||||
`
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "config.yaml")
|
|
||||||
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set env for client secret
|
|
||||||
t.Setenv("AUTH_CLIENT_SECRET", "test-secret-value")
|
|
||||||
|
|
||||||
_, _, _, auth, err := config.LoadRoutesAndModelsFromFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !auth.Enabled {
|
|
||||||
t.Error("auth should be enabled")
|
|
||||||
}
|
|
||||||
if auth.TokenURL != "https://authentik.example.com/application/o/token/" {
|
|
||||||
t.Errorf("tokenUrl = %q, want authentik token endpoint", auth.TokenURL)
|
|
||||||
}
|
|
||||||
if auth.ClientID != "api-gw" {
|
|
||||||
t.Errorf("clientId = %q, want api-gw", auth.ClientID)
|
|
||||||
}
|
|
||||||
if auth.ClientSecret != "test-secret-value" {
|
|
||||||
t.Errorf("clientSecret = %q, want test-secret-value", auth.ClientSecret)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadAuthConfig_ClientSecretFromEnvOnly(t *testing.T) {
|
|
||||||
yaml := `
|
|
||||||
routes: []
|
|
||||||
models: []
|
|
||||||
auth:
|
|
||||||
enabled: true
|
|
||||||
tokenUrl: "https://example.com/token/"
|
|
||||||
clientId: "test"
|
|
||||||
`
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "config.yaml")
|
|
||||||
os.WriteFile(path, []byte(yaml), 0644)
|
|
||||||
|
|
||||||
// No AUTH_CLIENT_SECRET env set
|
|
||||||
t.Setenv("AUTH_CLIENT_SECRET", "")
|
|
||||||
|
|
||||||
_, _, _, auth, err := config.LoadRoutesAndModelsFromFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if auth.ClientSecret != "" {
|
|
||||||
t.Errorf("clientSecret should be empty when env not set, got %q", auth.ClientSecret)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadAuthConfig_BackwardCompatible(t *testing.T) {
|
|
||||||
// Config without tokenUrl/clientId should still load (zero values)
|
|
||||||
yaml := `
|
|
||||||
routes: []
|
|
||||||
models: []
|
|
||||||
auth:
|
|
||||||
enabled: true
|
|
||||||
issuer: "https://example.com/"
|
|
||||||
jwksUrl: "https://example.com/jwks/"
|
|
||||||
requiredCapability: "llm:inference"
|
|
||||||
`
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "config.yaml")
|
|
||||||
os.WriteFile(path, []byte(yaml), 0644)
|
|
||||||
|
|
||||||
_, _, _, auth, err := config.LoadRoutesAndModelsFromFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if auth.TokenURL != "" {
|
|
||||||
t.Errorf("tokenUrl should be empty, got %q", auth.TokenURL)
|
|
||||||
}
|
|
||||||
if auth.ClientID != "" {
|
|
||||||
t.Errorf("clientId should be empty, got %q", auth.ClientID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,8 +24,6 @@ type Config struct {
|
|||||||
Adapters []*serviceadapter.ServiceAdapter
|
Adapters []*serviceadapter.ServiceAdapter
|
||||||
// Auth holds JWT authentication configuration for /v1/* endpoints.
|
// Auth holds JWT authentication configuration for /v1/* endpoints.
|
||||||
Auth AuthConfig
|
Auth AuthConfig
|
||||||
// Temporal holds Temporal server configuration.
|
|
||||||
Temporal TemporalConfig
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ModelUpstream holds upstream configuration for a specific model.
|
// ModelUpstream holds upstream configuration for a specific model.
|
||||||
@@ -40,12 +38,6 @@ type ModelUpstream struct {
|
|||||||
AuthRequired bool
|
AuthRequired bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// TemporalConfig holds Temporal server configuration.
|
|
||||||
type TemporalConfig struct {
|
|
||||||
// HostPort is the address of the Temporal server (host:port).
|
|
||||||
HostPort string
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuthConfig holds JWT authentication configuration.
|
// AuthConfig holds JWT authentication configuration.
|
||||||
type AuthConfig struct {
|
type AuthConfig struct {
|
||||||
// Enabled globally enables/disables auth for /v1/* endpoints.
|
// Enabled globally enables/disables auth for /v1/* endpoints.
|
||||||
@@ -58,12 +50,6 @@ type AuthConfig struct {
|
|||||||
JWKSURL string
|
JWKSURL string
|
||||||
// RequiredCapability is the permission required for LLM inference (e.g., "llm:inference").
|
// RequiredCapability is the permission required for LLM inference (e.g., "llm:inference").
|
||||||
RequiredCapability string
|
RequiredCapability string
|
||||||
// TokenURL is the Authentik token endpoint for password/refresh grants.
|
|
||||||
TokenURL string
|
|
||||||
// ClientID is the OAuth2 client ID for token exchange.
|
|
||||||
ClientID string
|
|
||||||
// ClientSecret is the OAuth2 client secret (loaded from env, never from config file).
|
|
||||||
ClientSecret string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route represents a single route and its upstream configuration.
|
// Route represents a single route and its upstream configuration.
|
||||||
@@ -146,12 +132,6 @@ func Load() (*Config, error) {
|
|||||||
authConfig = loadedAuth
|
authConfig = loadedAuth
|
||||||
}
|
}
|
||||||
|
|
||||||
temporalHostPort := "localhost:7233"
|
|
||||||
// Allow override via environment variable
|
|
||||||
if hostPort, ok := os.LookupEnv("TEMPORAL_HOST_PORT"); ok {
|
|
||||||
temporalHostPort = hostPort
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Config{
|
return &Config{
|
||||||
ListenAddr: listenAddr,
|
ListenAddr: listenAddr,
|
||||||
ShutdownTimeout: shutdownTimeout,
|
ShutdownTimeout: shutdownTimeout,
|
||||||
@@ -159,8 +139,5 @@ func Load() (*Config, error) {
|
|||||||
Models: models,
|
Models: models,
|
||||||
Adapters: adapters,
|
Adapters: adapters,
|
||||||
Auth: authConfig,
|
Auth: authConfig,
|
||||||
Temporal: TemporalConfig{
|
|
||||||
HostPort: temporalHostPort,
|
|
||||||
},
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ type rawAuth struct {
|
|||||||
Audience string `yaml:"audience"`
|
Audience string `yaml:"audience"`
|
||||||
JWKSURL string `yaml:"jwksUrl"`
|
JWKSURL string `yaml:"jwksUrl"`
|
||||||
RequiredCapability string `yaml:"requiredCapability"`
|
RequiredCapability string `yaml:"requiredCapability"`
|
||||||
TokenURL string `yaml:"tokenUrl"`
|
|
||||||
ClientID string `yaml:"clientId"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// rawRoute represents a single route in the YAML configuration.
|
// rawRoute represents a single route in the YAML configuration.
|
||||||
@@ -178,9 +176,6 @@ func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*Mo
|
|||||||
Audience: raw.Auth.Audience,
|
Audience: raw.Auth.Audience,
|
||||||
JWKSURL: raw.Auth.JWKSURL,
|
JWKSURL: raw.Auth.JWKSURL,
|
||||||
RequiredCapability: raw.Auth.RequiredCapability,
|
RequiredCapability: raw.Auth.RequiredCapability,
|
||||||
TokenURL: raw.Auth.TokenURL,
|
|
||||||
ClientID: raw.Auth.ClientID,
|
|
||||||
ClientSecret: os.Getenv("AUTH_CLIENT_SECRET"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return routes, models, adapters, authConfig, nil
|
return routes, models, adapters, authConfig, nil
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
// Package identity extracts authenticated user identity from JWT claims
|
|
||||||
// and injects forwarding headers into proxied requests.
|
|
||||||
//
|
|
||||||
// Headers injected after JWT validation:
|
|
||||||
//
|
|
||||||
// X-Forwarded-User: subject (sub claim)
|
|
||||||
// X-Forwarded-Roles: comma-separated roles or permissions
|
|
||||||
// X-Acting-Service: authorized party (azp claim), only for service accounts
|
|
||||||
// X-Auth-Verified: "true" when gateway validated the JWT
|
|
||||||
//
|
|
||||||
// Security contract: downstream services MUST only accept traffic from the
|
|
||||||
// gateway (enforced by NetworkPolicy). They trust these headers because the
|
|
||||||
// gateway is the sole ingress path.
|
|
||||||
package identity
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Headers that the gateway controls. Incoming values from clients are
|
|
||||||
// stripped to prevent spoofing.
|
|
||||||
const (
|
|
||||||
HeaderUser = "X-Forwarded-User"
|
|
||||||
HeaderRoles = "X-Forwarded-Roles"
|
|
||||||
HeaderActingService = "X-Acting-Service"
|
|
||||||
HeaderAuthVerified = "X-Auth-Verified"
|
|
||||||
)
|
|
||||||
|
|
||||||
// managed lists all headers this package owns. Used for stripping and cleanup.
|
|
||||||
var managed = []string{
|
|
||||||
HeaderUser,
|
|
||||||
HeaderRoles,
|
|
||||||
HeaderActingService,
|
|
||||||
HeaderAuthVerified,
|
|
||||||
}
|
|
||||||
|
|
||||||
// StripIncoming removes all gateway-managed identity headers from an
|
|
||||||
// inbound request, preventing clients from spoofing identity.
|
|
||||||
// Call this early in the handler chain, before any routing.
|
|
||||||
func StripIncoming(r *http.Request) {
|
|
||||||
for _, h := range managed {
|
|
||||||
r.Header.Del(h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inject extracts identity from validated JWT claims and sets the
|
|
||||||
// corresponding forwarding headers on the request. Only call this
|
|
||||||
// after successful JWT validation.
|
|
||||||
func Inject(r *http.Request, claims jwt.MapClaims) {
|
|
||||||
r.Header.Set(HeaderAuthVerified, "true")
|
|
||||||
|
|
||||||
if sub := claimString(claims, "sub"); sub != "" {
|
|
||||||
r.Header.Set(HeaderUser, sub)
|
|
||||||
}
|
|
||||||
|
|
||||||
if roles := claimStringSlice(claims, "roles"); len(roles) > 0 {
|
|
||||||
r.Header.Set(HeaderRoles, strings.Join(roles, ","))
|
|
||||||
} else if perms := claimStringSlice(claims, "permissions"); len(perms) > 0 {
|
|
||||||
r.Header.Set(HeaderRoles, strings.Join(perms, ","))
|
|
||||||
}
|
|
||||||
|
|
||||||
if azp := claimString(claims, "azp"); azp != "" {
|
|
||||||
sub := claimString(claims, "sub")
|
|
||||||
// Only set acting-service when azp differs from sub
|
|
||||||
// (i.e., a service account acting, not the user themselves)
|
|
||||||
if azp != sub {
|
|
||||||
r.Header.Set(HeaderActingService, azp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// claimString extracts a string value from claims, returning "" if
|
|
||||||
// the key is missing or not a string.
|
|
||||||
func claimString(claims jwt.MapClaims, key string) string {
|
|
||||||
val, ok := claims[key]
|
|
||||||
if !ok || val == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
s, ok := val.(string)
|
|
||||||
if !ok {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// claimStringSlice extracts a []string from claims. JWT libraries
|
|
||||||
// deserialize JSON arrays as []interface{}, so each element is
|
|
||||||
// type-asserted individually. Non-string elements are skipped.
|
|
||||||
func claimStringSlice(claims jwt.MapClaims, key string) []string {
|
|
||||||
val, ok := claims[key]
|
|
||||||
if !ok || val == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
raw, ok := val.([]interface{})
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]string, 0, len(raw))
|
|
||||||
for _, v := range raw {
|
|
||||||
if s, ok := v.(string); ok && s != "" {
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
package identity
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestStripIncoming_RemovesSpoofedHeaders(t *testing.T) {
|
|
||||||
r := httptest.NewRequest("GET", "/", nil)
|
|
||||||
r.Header.Set(HeaderUser, "evil-spoof")
|
|
||||||
r.Header.Set(HeaderRoles, "admin:*")
|
|
||||||
r.Header.Set(HeaderActingService, "fake-service")
|
|
||||||
r.Header.Set(HeaderAuthVerified, "true")
|
|
||||||
|
|
||||||
StripIncoming(r)
|
|
||||||
|
|
||||||
for _, h := range managed {
|
|
||||||
if got := r.Header.Get(h); got != "" {
|
|
||||||
t.Errorf("header %s should be stripped, got %q", h, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStripIncoming_PreservesOtherHeaders(t *testing.T) {
|
|
||||||
r := httptest.NewRequest("GET", "/", nil)
|
|
||||||
r.Header.Set("Authorization", "Bearer token")
|
|
||||||
r.Header.Set("Content-Type", "application/json")
|
|
||||||
r.Header.Set(HeaderUser, "spoof")
|
|
||||||
|
|
||||||
StripIncoming(r)
|
|
||||||
|
|
||||||
if got := r.Header.Get("Authorization"); got != "Bearer token" {
|
|
||||||
t.Errorf("Authorization should be preserved, got %q", got)
|
|
||||||
}
|
|
||||||
if got := r.Header.Get("Content-Type"); got != "application/json" {
|
|
||||||
t.Errorf("Content-Type should be preserved, got %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInject_ServiceAccount(t *testing.T) {
|
|
||||||
r := httptest.NewRequest("GET", "/", nil)
|
|
||||||
claims := jwt.MapClaims{
|
|
||||||
"sub": "abc123-hashed-id",
|
|
||||||
"azp": "portfolio-agent",
|
|
||||||
"roles": []interface{}{"llm:inference", "memory:read"},
|
|
||||||
}
|
|
||||||
|
|
||||||
Inject(r, claims)
|
|
||||||
|
|
||||||
assertHeader(t, r, HeaderAuthVerified, "true")
|
|
||||||
assertHeader(t, r, HeaderUser, "abc123-hashed-id")
|
|
||||||
assertHeader(t, r, HeaderRoles, "llm:inference,memory:read")
|
|
||||||
assertHeader(t, r, HeaderActingService, "portfolio-agent")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInject_HumanUser(t *testing.T) {
|
|
||||||
r := httptest.NewRequest("GET", "/", nil)
|
|
||||||
claims := jwt.MapClaims{
|
|
||||||
"sub": "user-hash-456",
|
|
||||||
"azp": "api-gw",
|
|
||||||
"permissions": []interface{}{"*"},
|
|
||||||
}
|
|
||||||
|
|
||||||
Inject(r, claims)
|
|
||||||
|
|
||||||
assertHeader(t, r, HeaderAuthVerified, "true")
|
|
||||||
assertHeader(t, r, HeaderUser, "user-hash-456")
|
|
||||||
assertHeader(t, r, HeaderRoles, "*")
|
|
||||||
// azp != sub, so acting-service is set
|
|
||||||
assertHeader(t, r, HeaderActingService, "api-gw")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInject_SameSubAndAzp_NoActingService(t *testing.T) {
|
|
||||||
r := httptest.NewRequest("GET", "/", nil)
|
|
||||||
claims := jwt.MapClaims{
|
|
||||||
"sub": "portfolio-agent",
|
|
||||||
"azp": "portfolio-agent",
|
|
||||||
"roles": []interface{}{"llm:inference"},
|
|
||||||
}
|
|
||||||
|
|
||||||
Inject(r, claims)
|
|
||||||
|
|
||||||
assertHeader(t, r, HeaderActingService, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInject_RolesOverPermissions(t *testing.T) {
|
|
||||||
r := httptest.NewRequest("GET", "/", nil)
|
|
||||||
claims := jwt.MapClaims{
|
|
||||||
"sub": "user-1",
|
|
||||||
"roles": []interface{}{"llm:inference"},
|
|
||||||
"permissions": []interface{}{"admin:*"},
|
|
||||||
}
|
|
||||||
|
|
||||||
Inject(r, claims)
|
|
||||||
|
|
||||||
// roles takes precedence over permissions
|
|
||||||
assertHeader(t, r, HeaderRoles, "llm:inference")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInject_PermissionsFallback(t *testing.T) {
|
|
||||||
r := httptest.NewRequest("GET", "/", nil)
|
|
||||||
claims := jwt.MapClaims{
|
|
||||||
"sub": "user-1",
|
|
||||||
"permissions": []interface{}{"grafana:read", "grafana:write"},
|
|
||||||
}
|
|
||||||
|
|
||||||
Inject(r, claims)
|
|
||||||
|
|
||||||
assertHeader(t, r, HeaderRoles, "grafana:read,grafana:write")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInject_EmptyClaims(t *testing.T) {
|
|
||||||
r := httptest.NewRequest("GET", "/", nil)
|
|
||||||
claims := jwt.MapClaims{}
|
|
||||||
|
|
||||||
Inject(r, claims)
|
|
||||||
|
|
||||||
assertHeader(t, r, HeaderAuthVerified, "true")
|
|
||||||
assertHeader(t, r, HeaderUser, "")
|
|
||||||
assertHeader(t, r, HeaderRoles, "")
|
|
||||||
assertHeader(t, r, HeaderActingService, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInject_NilValuesInClaims(t *testing.T) {
|
|
||||||
r := httptest.NewRequest("GET", "/", nil)
|
|
||||||
claims := jwt.MapClaims{
|
|
||||||
"sub": nil,
|
|
||||||
"azp": nil,
|
|
||||||
"roles": nil,
|
|
||||||
}
|
|
||||||
|
|
||||||
Inject(r, claims)
|
|
||||||
|
|
||||||
assertHeader(t, r, HeaderAuthVerified, "true")
|
|
||||||
assertHeader(t, r, HeaderUser, "")
|
|
||||||
assertHeader(t, r, HeaderRoles, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInject_WildcardPermission(t *testing.T) {
|
|
||||||
r := httptest.NewRequest("GET", "/", nil)
|
|
||||||
claims := jwt.MapClaims{
|
|
||||||
"sub": "admin-user",
|
|
||||||
"permissions": []interface{}{"*"},
|
|
||||||
}
|
|
||||||
|
|
||||||
Inject(r, claims)
|
|
||||||
|
|
||||||
// Wildcard passed as literal, never expanded
|
|
||||||
assertHeader(t, r, HeaderRoles, "*")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInject_MixedTypeRolesArray(t *testing.T) {
|
|
||||||
r := httptest.NewRequest("GET", "/", nil)
|
|
||||||
claims := jwt.MapClaims{
|
|
||||||
"sub": "user-1",
|
|
||||||
"roles": []interface{}{"llm:inference", 42, nil, "", "memory:read"},
|
|
||||||
}
|
|
||||||
|
|
||||||
Inject(r, claims)
|
|
||||||
|
|
||||||
// Non-string and empty elements skipped
|
|
||||||
assertHeader(t, r, HeaderRoles, "llm:inference,memory:read")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInject_EmptyRolesArray(t *testing.T) {
|
|
||||||
r := httptest.NewRequest("GET", "/", nil)
|
|
||||||
claims := jwt.MapClaims{
|
|
||||||
"sub": "user-1",
|
|
||||||
"roles": []interface{}{},
|
|
||||||
"permissions": []interface{}{"backup:read"},
|
|
||||||
}
|
|
||||||
|
|
||||||
Inject(r, claims)
|
|
||||||
|
|
||||||
// Empty roles falls through to permissions
|
|
||||||
assertHeader(t, r, HeaderRoles, "backup:read")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStripThenInject_OverwritesSpoof(t *testing.T) {
|
|
||||||
r := httptest.NewRequest("GET", "/", nil)
|
|
||||||
r.Header.Set(HeaderUser, "evil-spoof")
|
|
||||||
r.Header.Set(HeaderAuthVerified, "true")
|
|
||||||
|
|
||||||
StripIncoming(r)
|
|
||||||
|
|
||||||
claims := jwt.MapClaims{
|
|
||||||
"sub": "real-user",
|
|
||||||
"roles": []interface{}{"llm:inference"},
|
|
||||||
}
|
|
||||||
Inject(r, claims)
|
|
||||||
|
|
||||||
assertHeader(t, r, HeaderUser, "real-user")
|
|
||||||
assertHeader(t, r, HeaderAuthVerified, "true")
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertHeader(t *testing.T, r *http.Request, key, want string) {
|
|
||||||
t.Helper()
|
|
||||||
got := r.Header.Get(key)
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("header %s = %q, want %q", key, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
//go:build integration
|
|
||||||
|
|
||||||
package integration
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
gatewayBaseURL = "http://api-gateway:8080"
|
|
||||||
timeout = 30 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestIntegrationMemoryService tests memory adapter (ingest, query)
|
|
||||||
func TestIntegrationMemoryService(t *testing.T) {
|
|
||||||
if testing.Short() {
|
|
||||||
t.Skip("skipping integration test")
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: timeout}
|
|
||||||
|
|
||||||
// Test 1: Ingest memory
|
|
||||||
t.Log("Testing memory ingest...")
|
|
||||||
ingestPayload := map[string]interface{}{
|
|
||||||
"ingest_id": "test-ingest-" + fmt.Sprintf("%d", time.Now().Unix()),
|
|
||||||
"project": "test-project",
|
|
||||||
"title": "Integration Test Memory",
|
|
||||||
"content": "This is a test memory entry from integration test",
|
|
||||||
"tags": []string{"integration", "test"},
|
|
||||||
"source": "integration-test",
|
|
||||||
}
|
|
||||||
|
|
||||||
ingestBody, _ := json.Marshal(ingestPayload)
|
|
||||||
req, _ := http.NewRequest("POST", gatewayBaseURL+"/memory/ingest", bytes.NewReader(ingestBody))
|
|
||||||
req.Header.Set("X-Service", "memory")
|
|
||||||
req.Header.Set("X-Resource", "ingest")
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("memory ingest request failed: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
t.Logf("Ingest response: %d - %s", resp.StatusCode, string(body))
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
|
||||||
t.Fatalf("memory ingest failed with status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Log("✓ Memory ingest successful")
|
|
||||||
|
|
||||||
// Test 2: Query memory
|
|
||||||
t.Log("Testing memory query...")
|
|
||||||
queryPayload := map[string]interface{}{
|
|
||||||
"query": "integration test",
|
|
||||||
}
|
|
||||||
|
|
||||||
queryBody, _ := json.Marshal(queryPayload)
|
|
||||||
req, _ = http.NewRequest("POST", gatewayBaseURL+"/memory/query", bytes.NewReader(queryBody))
|
|
||||||
req.Header.Set("X-Service", "memory")
|
|
||||||
req.Header.Set("X-Resource", "query")
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
resp, err = client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("memory query request failed: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, _ = io.ReadAll(resp.Body)
|
|
||||||
t.Logf("Query response: %d - %s", resp.StatusCode, string(body))
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("memory query failed with status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Log("✓ Memory query successful")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIntegrationS3Service tests S3 adapter (list, put, get)
|
|
||||||
func TestIntegrationS3Service(t *testing.T) {
|
|
||||||
if testing.Short() {
|
|
||||||
t.Skip("skipping integration test")
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: timeout}
|
|
||||||
|
|
||||||
// Test 1: List objects
|
|
||||||
t.Log("Testing S3 list objects...")
|
|
||||||
req, _ := http.NewRequest("GET", gatewayBaseURL+"/", nil)
|
|
||||||
req.Header.Set("X-Service", "s3")
|
|
||||||
req.Header.Set("X-Resource", "list-objects")
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("S3 list request failed: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
t.Logf("List response: %d - %s", resp.StatusCode, string(body)[:100])
|
|
||||||
|
|
||||||
// S3 should respond with either 200 (list) or 403 (access denied) - both mean routing works
|
|
||||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusForbidden {
|
|
||||||
t.Fatalf("S3 list failed with unexpected status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Log("✓ S3 list objects successful")
|
|
||||||
|
|
||||||
// Test 2: Put object to dedicated test bucket
|
|
||||||
t.Log("Testing S3 put object...")
|
|
||||||
testContent := fmt.Sprintf("Integration test data - %d", time.Now().Unix())
|
|
||||||
testKey := "test-file-" + fmt.Sprintf("%d", time.Now().Unix()) + ".txt"
|
|
||||||
|
|
||||||
req, _ = http.NewRequest("PUT", gatewayBaseURL+"/"+testKey, bytes.NewReader([]byte(testContent)))
|
|
||||||
req.Header.Set("X-Service", "s3")
|
|
||||||
req.Header.Set("X-Resource", "put-object")
|
|
||||||
req.Header.Set("Content-Type", "text/plain")
|
|
||||||
|
|
||||||
resp, err = client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("S3 put request failed: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, _ = io.ReadAll(resp.Body)
|
|
||||||
t.Logf("Put response: %d - %s", resp.StatusCode, string(body)[:100])
|
|
||||||
|
|
||||||
// Put should respond - either success or S3 error (both mean routing works)
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 600 {
|
|
||||||
t.Fatalf("S3 put failed with status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Log("✓ S3 put object successful")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIntegrationSQSService tests SQS adapter (create queue, send, receive)
|
|
||||||
func TestIntegrationSQSService(t *testing.T) {
|
|
||||||
if testing.Short() {
|
|
||||||
t.Skip("skipping integration test")
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: timeout}
|
|
||||||
|
|
||||||
// Test 1: List queues (no auth required in test, auth error is ok)
|
|
||||||
t.Log("Testing SQS list queues...")
|
|
||||||
req, _ := http.NewRequest("GET", gatewayBaseURL+"/sqs/queues", nil)
|
|
||||||
req.Header.Set("X-Service", "sqs")
|
|
||||||
req.Header.Set("X-Resource", "list-queues")
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SQS list request failed: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
t.Logf("List queues response: %d - %s", resp.StatusCode, string(body))
|
|
||||||
|
|
||||||
// SQS requires auth, so 401 is expected but proves routing works
|
|
||||||
if resp.StatusCode == http.StatusUnauthorized {
|
|
||||||
t.Log("✓ SQS correctly requires authorization (routing works)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusOK {
|
|
||||||
t.Log("✓ SQS list queues successful")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Fatalf("SQS list failed with unexpected status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIntegrationWorkflowService tests workflow adapter (list, describe)
|
|
||||||
func TestIntegrationWorkflowService(t *testing.T) {
|
|
||||||
if testing.Short() {
|
|
||||||
t.Skip("skipping integration test")
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: timeout}
|
|
||||||
|
|
||||||
// Test 1: List workflows with gRPC
|
|
||||||
t.Log("Testing workflow list (gRPC)...")
|
|
||||||
req, _ := http.NewRequest("GET",
|
|
||||||
gatewayBaseURL+"/temporal.api.workflowservice.v1.WorkflowService/ListWorkflowExecutions",
|
|
||||||
nil)
|
|
||||||
req.Header.Set("X-Service", "workflow")
|
|
||||||
req.Header.Set("X-Resource", "list")
|
|
||||||
req.Header.Set("Content-Type", "application/grpc")
|
|
||||||
req.Header.Set("TE", "trailers")
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("workflow list request failed: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
|
|
||||||
// gRPC responses are binary, but we can check status code
|
|
||||||
t.Logf("List workflows response: %d (body length: %d bytes)", resp.StatusCode, len(body))
|
|
||||||
|
|
||||||
// Status 200 with gRPC binary data, or 501 if not yet implemented
|
|
||||||
if resp.StatusCode == http.StatusOK {
|
|
||||||
t.Log("✓ Workflow list successful (gRPC forwarding working)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusNotImplemented {
|
|
||||||
t.Log("⚠ Workflow list: gRPC forwarding not yet implemented")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
|
|
||||||
// Client error might indicate routing works but request format issue
|
|
||||||
t.Logf("✓ Workflow adapter routing confirmed (status %d)", resp.StatusCode)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Fatalf("Workflow list failed with status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIntegrationIAMService tests IAM adapter
|
|
||||||
func TestIntegrationIAMService(t *testing.T) {
|
|
||||||
if testing.Short() {
|
|
||||||
t.Skip("skipping integration test")
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: timeout}
|
|
||||||
|
|
||||||
t.Log("Testing IAM list users...")
|
|
||||||
req, _ := http.NewRequest("GET", gatewayBaseURL+"/api/v3/users", nil)
|
|
||||||
req.Header.Set("X-Service", "iam")
|
|
||||||
req.Header.Set("X-Resource", "list-users")
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("IAM list request failed: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
_, _ = io.ReadAll(resp.Body)
|
|
||||||
t.Logf("IAM list users response: %d", resp.StatusCode)
|
|
||||||
|
|
||||||
// IAM (Authentik) should respond - 200, 404, or auth error all prove routing works
|
|
||||||
if resp.StatusCode >= 200 && resp.StatusCode < 600 {
|
|
||||||
t.Log("✓ IAM adapter routing successful")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Fatalf("IAM list failed with status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIntegrationHealthChecks tests gateway health endpoints
|
|
||||||
func TestIntegrationHealthChecks(t *testing.T) {
|
|
||||||
if testing.Short() {
|
|
||||||
t.Skip("skipping integration test")
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: timeout}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
endpoint string
|
|
||||||
}{
|
|
||||||
{"liveness", "/healthz"},
|
|
||||||
{"readiness", "/readyz"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
req, _ := http.NewRequest("GET", gatewayBaseURL+tt.endpoint, nil)
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("health check request failed: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("health check failed with status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
var health map[string]string
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&health); err != nil {
|
|
||||||
t.Fatalf("failed to decode health response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("✓ %s: %s", tt.name, health["status"])
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/logging"
|
|
||||||
)
|
|
||||||
|
|
||||||
// tokenRequest is the JSON body for POST /auth/token.
|
|
||||||
type tokenRequest struct {
|
|
||||||
Username string `json:"username"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
Scope string `json:"scope,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// refreshRequest is the JSON body for POST /auth/refresh.
|
|
||||||
type refreshRequest struct {
|
|
||||||
RefreshToken string `json:"refresh_token"`
|
|
||||||
Scope string `json:"scope,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// authClient handles token exchange with the upstream identity provider.
|
|
||||||
// Extracted for testability — production uses http.DefaultClient,
|
|
||||||
// tests inject a stub.
|
|
||||||
type authClient interface {
|
|
||||||
PostForm(url string, data url.Values) (*http.Response, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// httpAuthClient wraps http.Client to implement authClient.
|
|
||||||
type httpAuthClient struct {
|
|
||||||
client *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *httpAuthClient) PostForm(url string, data url.Values) (*http.Response, error) {
|
|
||||||
return c.client.PostForm(url, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newAuthClient() authClient {
|
|
||||||
return &httpAuthClient{
|
|
||||||
client: &http.Client{Timeout: 10 * time.Second},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleAuthToken exchanges username+password for a JWT via the upstream
|
|
||||||
// identity provider's token endpoint using grant_type=password.
|
|
||||||
func (h *Handler) handleAuthToken(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "POST" {
|
|
||||||
writeProblemDetail(w, http.StatusMethodNotAllowed,
|
|
||||||
"about:blank#method-not-allowed", "Method Not Allowed",
|
|
||||||
"POST only", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.config.Auth.TokenURL == "" || h.config.Auth.ClientID == "" {
|
|
||||||
writeProblemDetail(w, http.StatusServiceUnavailable,
|
|
||||||
"about:blank#not-configured", "Not Configured",
|
|
||||||
"token endpoint not configured", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req tokenRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeProblemDetail(w, http.StatusBadRequest,
|
|
||||||
"about:blank#bad-request", "Bad Request",
|
|
||||||
"invalid JSON body", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Username == "" || req.Password == "" {
|
|
||||||
writeProblemDetail(w, http.StatusBadRequest,
|
|
||||||
"about:blank#bad-request", "Bad Request",
|
|
||||||
"username and password are required", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
scope := req.Scope
|
|
||||||
if scope == "" {
|
|
||||||
scope = "openid roles permissions"
|
|
||||||
}
|
|
||||||
|
|
||||||
form := url.Values{
|
|
||||||
"grant_type": {"password"},
|
|
||||||
"username": {req.Username},
|
|
||||||
"password": {req.Password},
|
|
||||||
"client_id": {h.config.Auth.ClientID},
|
|
||||||
"client_secret": {h.config.Auth.ClientSecret},
|
|
||||||
"scope": {scope},
|
|
||||||
}
|
|
||||||
|
|
||||||
h.forwardTokenResponse(w, form, "/auth/token")
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleAuthRefresh exchanges a refresh token for a new JWT.
|
|
||||||
func (h *Handler) handleAuthRefresh(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "POST" {
|
|
||||||
writeProblemDetail(w, http.StatusMethodNotAllowed,
|
|
||||||
"about:blank#method-not-allowed", "Method Not Allowed",
|
|
||||||
"POST only", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.config.Auth.TokenURL == "" || h.config.Auth.ClientID == "" {
|
|
||||||
writeProblemDetail(w, http.StatusServiceUnavailable,
|
|
||||||
"about:blank#not-configured", "Not Configured",
|
|
||||||
"token endpoint not configured", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req refreshRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeProblemDetail(w, http.StatusBadRequest,
|
|
||||||
"about:blank#bad-request", "Bad Request",
|
|
||||||
"invalid JSON body", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.RefreshToken == "" {
|
|
||||||
writeProblemDetail(w, http.StatusBadRequest,
|
|
||||||
"about:blank#bad-request", "Bad Request",
|
|
||||||
"refresh_token is required", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
form := url.Values{
|
|
||||||
"grant_type": {"refresh_token"},
|
|
||||||
"refresh_token": {req.RefreshToken},
|
|
||||||
"client_id": {h.config.Auth.ClientID},
|
|
||||||
"client_secret": {h.config.Auth.ClientSecret},
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Scope != "" {
|
|
||||||
form.Set("scope", req.Scope)
|
|
||||||
}
|
|
||||||
|
|
||||||
h.forwardTokenResponse(w, form, "/auth/refresh")
|
|
||||||
}
|
|
||||||
|
|
||||||
// forwardTokenResponse posts form data to the token endpoint and
|
|
||||||
// forwards the response verbatim to the client.
|
|
||||||
func (h *Handler) forwardTokenResponse(w http.ResponseWriter, form url.Values, logPath string) {
|
|
||||||
resp, err := h.authHTTP.PostForm(h.config.Auth.TokenURL, form)
|
|
||||||
if err != nil {
|
|
||||||
writeProblemDetail(w, http.StatusBadGateway,
|
|
||||||
"about:blank#bad-gateway", "Bad Gateway",
|
|
||||||
"identity provider unreachable", nil)
|
|
||||||
logging.Errorf("auth upstream error", err, map[string]string{
|
|
||||||
"path": logPath,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(resp.StatusCode)
|
|
||||||
io.Copy(w, resp.Body)
|
|
||||||
}
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// stubAuthClient captures the form data sent and returns a canned response.
|
|
||||||
type stubAuthClient struct {
|
|
||||||
lastForm url.Values
|
|
||||||
statusCode int
|
|
||||||
body string
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *stubAuthClient) PostForm(u string, data url.Values) (*http.Response, error) {
|
|
||||||
s.lastForm = data
|
|
||||||
if s.err != nil {
|
|
||||||
return nil, s.err
|
|
||||||
}
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: s.statusCode,
|
|
||||||
Body: io.NopCloser(strings.NewReader(s.body)),
|
|
||||||
Header: http.Header{"Content-Type": {"application/json"}},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestHandler(tokenURL, clientID, clientSecret string, client authClient) *Handler {
|
|
||||||
cfg := &config.Config{
|
|
||||||
Auth: config.AuthConfig{
|
|
||||||
TokenURL: tokenURL,
|
|
||||||
ClientID: clientID,
|
|
||||||
ClientSecret: clientSecret,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
h := &Handler{
|
|
||||||
config: cfg,
|
|
||||||
authHTTP: client,
|
|
||||||
routes: make(map[string]*Route),
|
|
||||||
transports: make(map[string]*http.Transport),
|
|
||||||
}
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthToken_Success(t *testing.T) {
|
|
||||||
stub := &stubAuthClient{
|
|
||||||
statusCode: 200,
|
|
||||||
body: `{"access_token":"jwt.token.here","refresh_token":"refresh","expires_in":3600}`,
|
|
||||||
}
|
|
||||||
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", stub)
|
|
||||||
|
|
||||||
body := `{"username":"rock","password":"pass123"}`
|
|
||||||
r := httptest.NewRequest("POST", "/auth/token", strings.NewReader(body))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.handleAuthToken(w, r)
|
|
||||||
|
|
||||||
if w.Code != 200 {
|
|
||||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify form sent to upstream
|
|
||||||
if stub.lastForm.Get("grant_type") != "password" {
|
|
||||||
t.Errorf("grant_type = %q, want password", stub.lastForm.Get("grant_type"))
|
|
||||||
}
|
|
||||||
if stub.lastForm.Get("username") != "rock" {
|
|
||||||
t.Errorf("username = %q, want rock", stub.lastForm.Get("username"))
|
|
||||||
}
|
|
||||||
if stub.lastForm.Get("client_id") != "api-gw" {
|
|
||||||
t.Errorf("client_id = %q, want api-gw", stub.lastForm.Get("client_id"))
|
|
||||||
}
|
|
||||||
if stub.lastForm.Get("client_secret") != "secret" {
|
|
||||||
t.Errorf("client_secret = %q, want secret", stub.lastForm.Get("client_secret"))
|
|
||||||
}
|
|
||||||
if stub.lastForm.Get("scope") != "openid roles permissions" {
|
|
||||||
t.Errorf("scope = %q, want default scope", stub.lastForm.Get("scope"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify response forwarded
|
|
||||||
var resp map[string]interface{}
|
|
||||||
json.NewDecoder(w.Body).Decode(&resp)
|
|
||||||
if resp["access_token"] != "jwt.token.here" {
|
|
||||||
t.Errorf("access_token not forwarded")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthToken_CustomScope(t *testing.T) {
|
|
||||||
stub := &stubAuthClient{statusCode: 200, body: `{}`}
|
|
||||||
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", stub)
|
|
||||||
|
|
||||||
body := `{"username":"rock","password":"pass","scope":"openid roles"}`
|
|
||||||
r := httptest.NewRequest("POST", "/auth/token", strings.NewReader(body))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.handleAuthToken(w, r)
|
|
||||||
|
|
||||||
if stub.lastForm.Get("scope") != "openid roles" {
|
|
||||||
t.Errorf("scope = %q, want custom scope", stub.lastForm.Get("scope"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthToken_MissingUsername(t *testing.T) {
|
|
||||||
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{})
|
|
||||||
|
|
||||||
body := `{"password":"pass"}`
|
|
||||||
r := httptest.NewRequest("POST", "/auth/token", strings.NewReader(body))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.handleAuthToken(w, r)
|
|
||||||
|
|
||||||
if w.Code != 400 {
|
|
||||||
t.Errorf("expected 400, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthToken_MissingPassword(t *testing.T) {
|
|
||||||
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{})
|
|
||||||
|
|
||||||
body := `{"username":"rock"}`
|
|
||||||
r := httptest.NewRequest("POST", "/auth/token", strings.NewReader(body))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.handleAuthToken(w, r)
|
|
||||||
|
|
||||||
if w.Code != 400 {
|
|
||||||
t.Errorf("expected 400, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthToken_InvalidJSON(t *testing.T) {
|
|
||||||
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{})
|
|
||||||
|
|
||||||
r := httptest.NewRequest("POST", "/auth/token", strings.NewReader("not json"))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.handleAuthToken(w, r)
|
|
||||||
|
|
||||||
if w.Code != 400 {
|
|
||||||
t.Errorf("expected 400, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthToken_WrongMethod(t *testing.T) {
|
|
||||||
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{})
|
|
||||||
|
|
||||||
r := httptest.NewRequest("GET", "/auth/token", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.handleAuthToken(w, r)
|
|
||||||
|
|
||||||
if w.Code != 405 {
|
|
||||||
t.Errorf("expected 405, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthToken_NotConfigured(t *testing.T) {
|
|
||||||
h := newTestHandler("", "", "", &stubAuthClient{})
|
|
||||||
|
|
||||||
body := `{"username":"rock","password":"pass"}`
|
|
||||||
r := httptest.NewRequest("POST", "/auth/token", strings.NewReader(body))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.handleAuthToken(w, r)
|
|
||||||
|
|
||||||
if w.Code != 503 {
|
|
||||||
t.Errorf("expected 503, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthToken_UpstreamError(t *testing.T) {
|
|
||||||
stub := &stubAuthClient{err: io.ErrUnexpectedEOF}
|
|
||||||
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", stub)
|
|
||||||
|
|
||||||
body := `{"username":"rock","password":"pass"}`
|
|
||||||
r := httptest.NewRequest("POST", "/auth/token", strings.NewReader(body))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.handleAuthToken(w, r)
|
|
||||||
|
|
||||||
if w.Code != 502 {
|
|
||||||
t.Errorf("expected 502, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthToken_UpstreamRejectsCredentials(t *testing.T) {
|
|
||||||
stub := &stubAuthClient{
|
|
||||||
statusCode: 400,
|
|
||||||
body: `{"error":"invalid_grant","error_description":"bad password"}`,
|
|
||||||
}
|
|
||||||
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", stub)
|
|
||||||
|
|
||||||
body := `{"username":"rock","password":"wrong"}`
|
|
||||||
r := httptest.NewRequest("POST", "/auth/token", strings.NewReader(body))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.handleAuthToken(w, r)
|
|
||||||
|
|
||||||
// Upstream error forwarded verbatim
|
|
||||||
if w.Code != 400 {
|
|
||||||
t.Errorf("expected 400 (forwarded), got %d", w.Code)
|
|
||||||
}
|
|
||||||
if !strings.Contains(w.Body.String(), "invalid_grant") {
|
|
||||||
t.Errorf("expected upstream error forwarded, got %s", w.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- /auth/refresh tests ---
|
|
||||||
|
|
||||||
func TestAuthRefresh_Success(t *testing.T) {
|
|
||||||
stub := &stubAuthClient{
|
|
||||||
statusCode: 200,
|
|
||||||
body: `{"access_token":"new.jwt","refresh_token":"new.refresh","expires_in":3600}`,
|
|
||||||
}
|
|
||||||
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", stub)
|
|
||||||
|
|
||||||
body := `{"refresh_token":"old.refresh"}`
|
|
||||||
r := httptest.NewRequest("POST", "/auth/refresh", strings.NewReader(body))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.handleAuthRefresh(w, r)
|
|
||||||
|
|
||||||
if w.Code != 200 {
|
|
||||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
||||||
}
|
|
||||||
if stub.lastForm.Get("grant_type") != "refresh_token" {
|
|
||||||
t.Errorf("grant_type = %q, want refresh_token", stub.lastForm.Get("grant_type"))
|
|
||||||
}
|
|
||||||
if stub.lastForm.Get("refresh_token") != "old.refresh" {
|
|
||||||
t.Errorf("refresh_token not sent")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthRefresh_MissingToken(t *testing.T) {
|
|
||||||
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{})
|
|
||||||
|
|
||||||
r := httptest.NewRequest("POST", "/auth/refresh", strings.NewReader(`{}`))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.handleAuthRefresh(w, r)
|
|
||||||
|
|
||||||
if w.Code != 400 {
|
|
||||||
t.Errorf("expected 400, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthRefresh_WrongMethod(t *testing.T) {
|
|
||||||
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{})
|
|
||||||
|
|
||||||
r := httptest.NewRequest("GET", "/auth/refresh", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.handleAuthRefresh(w, r)
|
|
||||||
|
|
||||||
if w.Code != 405 {
|
|
||||||
t.Errorf("expected 405, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthRefresh_NotConfigured(t *testing.T) {
|
|
||||||
h := newTestHandler("", "", "", &stubAuthClient{})
|
|
||||||
|
|
||||||
r := httptest.NewRequest("POST", "/auth/refresh", strings.NewReader(`{"refresh_token":"x"}`))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.handleAuthRefresh(w, r)
|
|
||||||
|
|
||||||
if w.Code != 503 {
|
|
||||||
t.Errorf("expected 503, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAuthRefresh_ExpiredToken(t *testing.T) {
|
|
||||||
stub := &stubAuthClient{
|
|
||||||
statusCode: 401,
|
|
||||||
body: `{"error":"invalid_grant","error_description":"token expired"}`,
|
|
||||||
}
|
|
||||||
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", stub)
|
|
||||||
|
|
||||||
r := httptest.NewRequest("POST", "/auth/refresh", strings.NewReader(`{"refresh_token":"expired"}`))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.handleAuthRefresh(w, r)
|
|
||||||
|
|
||||||
if w.Code != 401 {
|
|
||||||
t.Errorf("expected 401 (forwarded), got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify no credentials are leaked in response bodies
|
|
||||||
func TestAuthToken_NoCredentialLeak(t *testing.T) {
|
|
||||||
stub := &stubAuthClient{statusCode: 200, body: `{"access_token":"tok"}`}
|
|
||||||
h := newTestHandler("https://auth.example.com/token/", "api-gw", "super-secret", stub)
|
|
||||||
|
|
||||||
body := `{"username":"rock","password":"my-password"}`
|
|
||||||
r := httptest.NewRequest("POST", "/auth/token", bytes.NewReader([]byte(body)))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.handleAuthToken(w, r)
|
|
||||||
|
|
||||||
respBody := w.Body.String()
|
|
||||||
if strings.Contains(respBody, "super-secret") {
|
|
||||||
t.Error("client_secret leaked in response")
|
|
||||||
}
|
|
||||||
if strings.Contains(respBody, "my-password") {
|
|
||||||
t.Error("password leaked in response")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,6 @@ import (
|
|||||||
|
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/identity"
|
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/logging"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/logging"
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/tracing"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/tracing"
|
||||||
)
|
)
|
||||||
@@ -29,8 +28,6 @@ type Handler struct {
|
|||||||
config *config.Config
|
config *config.Config
|
||||||
// jwtValidator validates JWT tokens for authenticated endpoints
|
// jwtValidator validates JWT tokens for authenticated endpoints
|
||||||
jwtValidator *auth.Validator
|
jwtValidator *auth.Validator
|
||||||
// authHTTP is the HTTP client for token exchange with the identity provider.
|
|
||||||
authHTTP authClient
|
|
||||||
// Default timeouts for synthesized routes (model-based dispatch)
|
// Default timeouts for synthesized routes (model-based dispatch)
|
||||||
defaultConnectTimeout time.Duration
|
defaultConnectTimeout time.Duration
|
||||||
defaultReadTimeout time.Duration
|
defaultReadTimeout time.Duration
|
||||||
@@ -88,10 +85,6 @@ func New(cfg *config.Config) *Handler {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Auth.TokenURL != "" {
|
|
||||||
h.authHTTP = newAuthClient()
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, route := range cfg.Routes {
|
for name, route := range cfg.Routes {
|
||||||
// Create a transport per unique upstream address for connection reuse
|
// Create a transport per unique upstream address for connection reuse
|
||||||
transport := h.getOrCreateTransport(route.Upstream.Address, &route.Upstream)
|
transport := h.getOrCreateTransport(route.Upstream.Address, &route.Upstream)
|
||||||
@@ -234,20 +227,6 @@ func writeProblemDetail(w http.ResponseWriter, status int, problemType, title, d
|
|||||||
|
|
||||||
// ServeHTTP implements http.Handler.
|
// ServeHTTP implements http.Handler.
|
||||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
// Auth endpoints — no JWT required (they issue tokens)
|
|
||||||
if r.URL.Path == "/auth/token" {
|
|
||||||
h.handleAuthToken(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.URL.Path == "/auth/refresh" {
|
|
||||||
h.handleAuthRefresh(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if r.URL.Path == "/auth/exchange" {
|
|
||||||
h.handleAuthExchange(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle /v1/models endpoint (no routing needed, derived from config)
|
// Handle /v1/models endpoint (no routing needed, derived from config)
|
||||||
if r.URL.Path == "/v1/models" && r.Method == "GET" {
|
if r.URL.Path == "/v1/models" && r.Method == "GET" {
|
||||||
h.handleModelsEndpoint(w, r)
|
h.handleModelsEndpoint(w, r)
|
||||||
@@ -324,10 +303,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip spoofed identity headers from all inbound requests.
|
|
||||||
// Must happen before any routing — even unauthenticated paths.
|
|
||||||
identity.StripIncoming(r)
|
|
||||||
|
|
||||||
// JWT Authentication for /v1/* endpoints
|
// JWT Authentication for /v1/* endpoints
|
||||||
if h.jwtValidator != nil && strings.HasPrefix(r.URL.Path, "/v1/") {
|
if h.jwtValidator != nil && strings.HasPrefix(r.URL.Path, "/v1/") {
|
||||||
authHeader := r.Header.Get("Authorization")
|
authHeader := r.Header.Get("Authorization")
|
||||||
@@ -356,9 +331,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject identity headers for downstream services
|
|
||||||
identity.Inject(r, claims)
|
|
||||||
|
|
||||||
// Check required capability if configured
|
// Check required capability if configured
|
||||||
if h.config.Auth.RequiredCapability != "" {
|
if h.config.Auth.RequiredCapability != "" {
|
||||||
if !h.jwtValidator.CheckPermissions(claims, h.config.Auth.RequiredCapability, "*") {
|
if !h.jwtValidator.CheckPermissions(claims, h.config.Auth.RequiredCapability, "*") {
|
||||||
|
|||||||
@@ -1,229 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/logging"
|
|
||||||
)
|
|
||||||
|
|
||||||
// exchangeRequest represents a token exchange request (RFC 8693 subset).
|
|
||||||
type exchangeRequest struct {
|
|
||||||
SubjectToken string `json:"subject_token"`
|
|
||||||
ClientID string `json:"client_id"`
|
|
||||||
ClientSecret string `json:"client_secret"`
|
|
||||||
Scope string `json:"scope,omitempty"`
|
|
||||||
Resource string `json:"resource,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// exchangeResponse wraps the service token with subject identity metadata.
|
|
||||||
type exchangeResponse struct {
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
TokenType string `json:"token_type"`
|
|
||||||
ExpiresIn int `json:"expires_in"`
|
|
||||||
IssuedTokenType string `json:"issued_token_type,omitempty"`
|
|
||||||
Subject string `json:"subject,omitempty"`
|
|
||||||
ActingParty string `json:"acting_party,omitempty"`
|
|
||||||
GrantedScope string `json:"scope,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleAuthExchange implements token exchange: a service presents a user's
|
|
||||||
// JWT and its own credentials to get a scoped service token with the user's
|
|
||||||
// identity attached.
|
|
||||||
//
|
|
||||||
// Flow:
|
|
||||||
// 1. Validate subject_token (user's JWT) — signature, expiry, issuer
|
|
||||||
// 2. Authenticate service via client_credentials against Authentik
|
|
||||||
// 3. Verify requested scope is a subset of service's roles
|
|
||||||
// 4. Return service token + subject identity metadata
|
|
||||||
func (h *Handler) handleAuthExchange(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != "POST" {
|
|
||||||
writeProblemDetail(w, http.StatusMethodNotAllowed,
|
|
||||||
"about:blank#method-not-allowed", "Method Not Allowed",
|
|
||||||
"POST only", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.jwtValidator == nil || h.config.Auth.TokenURL == "" {
|
|
||||||
writeProblemDetail(w, http.StatusServiceUnavailable,
|
|
||||||
"about:blank#not-configured", "Not Configured",
|
|
||||||
"token exchange not configured", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req exchangeRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeProblemDetail(w, http.StatusBadRequest,
|
|
||||||
"about:blank#bad-request", "Bad Request",
|
|
||||||
"invalid JSON body", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.SubjectToken == "" || req.ClientID == "" || req.ClientSecret == "" {
|
|
||||||
writeProblemDetail(w, http.StatusBadRequest,
|
|
||||||
"about:blank#bad-request", "Bad Request",
|
|
||||||
"subject_token, client_id, and client_secret are required", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Validate subject token
|
|
||||||
subjectClaims, err := h.jwtValidator.ValidateBearerToken("Bearer " + req.SubjectToken)
|
|
||||||
if err != nil {
|
|
||||||
writeProblemDetail(w, http.StatusForbidden,
|
|
||||||
"about:blank#invalid-subject-token", "Invalid Subject Token",
|
|
||||||
fmt.Sprintf("subject token validation failed: %v", err), nil)
|
|
||||||
logging.Errorf("token exchange: invalid subject", err, map[string]string{
|
|
||||||
"path": "/auth/exchange",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
subject := claimStr(subjectClaims, "sub")
|
|
||||||
|
|
||||||
// Step 2: Authenticate service via client_credentials
|
|
||||||
form := url.Values{
|
|
||||||
"grant_type": {"client_credentials"},
|
|
||||||
"client_id": {req.ClientID},
|
|
||||||
"client_secret": {req.ClientSecret},
|
|
||||||
"scope": {"openid roles"},
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := h.authHTTP.PostForm(h.config.Auth.TokenURL, form)
|
|
||||||
if err != nil {
|
|
||||||
writeProblemDetail(w, http.StatusBadGateway,
|
|
||||||
"about:blank#bad-gateway", "Bad Gateway",
|
|
||||||
"identity provider unreachable", nil)
|
|
||||||
logging.Errorf("token exchange: upstream error", err, map[string]string{
|
|
||||||
"path": "/auth/exchange",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
writeProblemDetail(w, http.StatusForbidden,
|
|
||||||
"about:blank#invalid-actor", "Invalid Actor Credentials",
|
|
||||||
fmt.Sprintf("service authentication failed (HTTP %d)", resp.StatusCode), nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var tokenResp struct {
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
ExpiresIn int `json:"expires_in"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
|
||||||
writeProblemDetail(w, http.StatusBadGateway,
|
|
||||||
"about:blank#bad-gateway", "Bad Gateway",
|
|
||||||
"invalid response from identity provider", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Decode service token to check roles
|
|
||||||
serviceRoles, err := extractRolesFromJWT(tokenResp.AccessToken)
|
|
||||||
if err != nil {
|
|
||||||
writeProblemDetail(w, http.StatusBadGateway,
|
|
||||||
"about:blank#bad-gateway", "Bad Gateway",
|
|
||||||
"cannot decode service token", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Scope != "" && !scopeSubset(req.Scope, serviceRoles) {
|
|
||||||
writeProblemDetail(w, http.StatusForbidden,
|
|
||||||
"about:blank#scope-escalation", "Scope Escalation Denied",
|
|
||||||
fmt.Sprintf("requested scope %q exceeds service roles %v", req.Scope, serviceRoles), nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
grantedScope := req.Scope
|
|
||||||
if grantedScope == "" {
|
|
||||||
grantedScope = strings.Join(serviceRoles, " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Return service token with subject metadata
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
json.NewEncoder(w).Encode(exchangeResponse{
|
|
||||||
AccessToken: tokenResp.AccessToken,
|
|
||||||
TokenType: "Bearer",
|
|
||||||
ExpiresIn: tokenResp.ExpiresIn,
|
|
||||||
IssuedTokenType: "urn:ietf:params:oauth:token-type:access_token",
|
|
||||||
Subject: subject,
|
|
||||||
ActingParty: req.ClientID,
|
|
||||||
GrantedScope: grantedScope,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// scopeSubset checks that every space-separated scope token is in allowed roles.
|
|
||||||
func scopeSubset(requested string, allowed []string) bool {
|
|
||||||
allowedSet := make(map[string]bool, len(allowed))
|
|
||||||
for _, r := range allowed {
|
|
||||||
allowedSet[r] = true
|
|
||||||
}
|
|
||||||
if allowedSet["*"] {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, s := range strings.Fields(requested) {
|
|
||||||
if !allowedSet[s] {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractRolesFromJWT decodes the payload of a JWT without verification
|
|
||||||
// and returns the "roles" claim. Used after the token was already obtained
|
|
||||||
// from a trusted source (Authentik client_credentials response).
|
|
||||||
func extractRolesFromJWT(token string) ([]string, error) {
|
|
||||||
parts := strings.SplitN(token, ".", 3)
|
|
||||||
if len(parts) != 3 {
|
|
||||||
return nil, fmt.Errorf("invalid JWT format")
|
|
||||||
}
|
|
||||||
|
|
||||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("base64 decode failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var claims map[string]interface{}
|
|
||||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
|
||||||
return nil, fmt.Errorf("JSON decode failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return claimStrSlice(claims, "roles"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// claimStr extracts a string claim.
|
|
||||||
func claimStr(claims map[string]interface{}, key string) string {
|
|
||||||
v, ok := claims[key]
|
|
||||||
if !ok || v == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
s, ok := v.(string)
|
|
||||||
if !ok {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// claimStrSlice extracts a string slice from a JSON-deserialized []interface{}.
|
|
||||||
func claimStrSlice(claims map[string]interface{}, key string) []string {
|
|
||||||
v, ok := claims[key]
|
|
||||||
if !ok || v == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
raw, ok := v.([]interface{})
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]string, 0, len(raw))
|
|
||||||
for _, item := range raw {
|
|
||||||
if s, ok := item.(string); ok && s != "" {
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
|
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fakeJWT creates a JWT-shaped string (header.payload.signature) with given claims.
|
|
||||||
// Not cryptographically signed — used only with stubbed validators.
|
|
||||||
func fakeJWT(claims map[string]interface{}) string {
|
|
||||||
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
|
|
||||||
payload, _ := json.Marshal(claims)
|
|
||||||
payloadB64 := base64.RawURLEncoding.EncodeToString(payload)
|
|
||||||
return header + "." + payloadB64 + ".fakesig"
|
|
||||||
}
|
|
||||||
|
|
||||||
// stubJWTValidator returns claims from a pre-set map keyed by token.
|
|
||||||
type stubJWTValidator struct {
|
|
||||||
tokens map[string]map[string]interface{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *stubJWTValidator) ValidateBearerToken(authHeader string) (map[string]interface{}, error) {
|
|
||||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
|
||||||
if claims, ok := s.tokens[token]; ok {
|
|
||||||
return claims, nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("invalid token")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *stubJWTValidator) CheckPermissions(claims map[string]interface{}, required ...string) bool {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// We can't use stubJWTValidator directly because Handler expects *auth.Validator.
|
|
||||||
// Instead, test via the endpoint with a real JWKS server or test the helpers directly.
|
|
||||||
|
|
||||||
func TestScopeSubset(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
requested string
|
|
||||||
allowed []string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"memory:read", []string{"llm:inference", "memory:read"}, true},
|
|
||||||
{"memory:read memory:write", []string{"memory:read", "memory:write"}, true},
|
|
||||||
{"memory:write", []string{"memory:read"}, false},
|
|
||||||
{"admin:*", []string{"memory:read"}, false},
|
|
||||||
{"anything", []string{"*"}, true},
|
|
||||||
{"", []string{"memory:read"}, true},
|
|
||||||
{"memory:read", []string{}, false},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
got := scopeSubset(tt.requested, tt.allowed)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("scopeSubset(%q, %v) = %v, want %v", tt.requested, tt.allowed, got, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractRolesFromJWT(t *testing.T) {
|
|
||||||
token := fakeJWT(map[string]interface{}{
|
|
||||||
"roles": []interface{}{"llm:inference", "memory:read"},
|
|
||||||
"sub": "test-user",
|
|
||||||
})
|
|
||||||
|
|
||||||
roles, err := extractRolesFromJWT(token)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if len(roles) != 2 || roles[0] != "llm:inference" || roles[1] != "memory:read" {
|
|
||||||
t.Errorf("roles = %v, want [llm:inference memory:read]", roles)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractRolesFromJWT_InvalidFormat(t *testing.T) {
|
|
||||||
_, err := extractRolesFromJWT("not-a-jwt")
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error for invalid JWT")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractRolesFromJWT_NoRoles(t *testing.T) {
|
|
||||||
token := fakeJWT(map[string]interface{}{"sub": "user"})
|
|
||||||
roles, err := extractRolesFromJWT(token)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if roles != nil {
|
|
||||||
t.Errorf("expected nil roles, got %v", roles)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleAuthExchange_WrongMethod(t *testing.T) {
|
|
||||||
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{})
|
|
||||||
h.jwtValidator = auth.NewValidator("", "", "")
|
|
||||||
|
|
||||||
r := httptest.NewRequest("GET", "/auth/exchange", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.handleAuthExchange(w, r)
|
|
||||||
|
|
||||||
if w.Code != 405 {
|
|
||||||
t.Errorf("expected 405, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleAuthExchange_NotConfigured(t *testing.T) {
|
|
||||||
h := &Handler{
|
|
||||||
config: &config.Config{},
|
|
||||||
routes: make(map[string]*Route),
|
|
||||||
transports: make(map[string]*http.Transport),
|
|
||||||
}
|
|
||||||
|
|
||||||
body := `{"subject_token":"x","client_id":"y","client_secret":"z"}`
|
|
||||||
r := httptest.NewRequest("POST", "/auth/exchange", strings.NewReader(body))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.handleAuthExchange(w, r)
|
|
||||||
|
|
||||||
if w.Code != 503 {
|
|
||||||
t.Errorf("expected 503, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleAuthExchange_MissingFields(t *testing.T) {
|
|
||||||
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{})
|
|
||||||
h.jwtValidator = auth.NewValidator("", "", "")
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
body string
|
|
||||||
}{
|
|
||||||
{"missing subject", `{"client_id":"x","client_secret":"y"}`},
|
|
||||||
{"missing client_id", `{"subject_token":"x","client_secret":"y"}`},
|
|
||||||
{"missing client_secret", `{"subject_token":"x","client_id":"y"}`},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
r := httptest.NewRequest("POST", "/auth/exchange", strings.NewReader(tt.body))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.handleAuthExchange(w, r)
|
|
||||||
if w.Code != 400 {
|
|
||||||
t.Errorf("expected 400, got %d", w.Code)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleAuthExchange_InvalidJSON(t *testing.T) {
|
|
||||||
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{})
|
|
||||||
h.jwtValidator = auth.NewValidator("", "", "")
|
|
||||||
|
|
||||||
r := httptest.NewRequest("POST", "/auth/exchange", strings.NewReader("not json"))
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h.handleAuthExchange(w, r)
|
|
||||||
|
|
||||||
if w.Code != 400 {
|
|
||||||
t.Errorf("expected 400, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHandleAuthExchange_ScopeEscalation(t *testing.T) {
|
|
||||||
// Test scopeSubset directly since full integration needs JWKS
|
|
||||||
if scopeSubset("admin:delete", []string{"memory:read", "memory:write"}) {
|
|
||||||
t.Error("scope escalation should be denied")
|
|
||||||
}
|
|
||||||
if !scopeSubset("memory:read", []string{"memory:read", "memory:write"}) {
|
|
||||||
t.Error("valid scope should be allowed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClaimStr(t *testing.T) {
|
|
||||||
claims := map[string]interface{}{"sub": "user-1", "num": 42, "nil": nil}
|
|
||||||
if got := claimStr(claims, "sub"); got != "user-1" {
|
|
||||||
t.Errorf("claimStr(sub) = %q, want user-1", got)
|
|
||||||
}
|
|
||||||
if got := claimStr(claims, "num"); got != "" {
|
|
||||||
t.Errorf("claimStr(num) = %q, want empty", got)
|
|
||||||
}
|
|
||||||
if got := claimStr(claims, "nil"); got != "" {
|
|
||||||
t.Errorf("claimStr(nil) = %q, want empty", got)
|
|
||||||
}
|
|
||||||
if got := claimStr(claims, "missing"); got != "" {
|
|
||||||
t.Errorf("claimStr(missing) = %q, want empty", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClaimStrSlice(t *testing.T) {
|
|
||||||
claims := map[string]interface{}{
|
|
||||||
"roles": []interface{}{"a", "b", "", nil, 42},
|
|
||||||
"empty": []interface{}{},
|
|
||||||
"str": "not-a-slice",
|
|
||||||
}
|
|
||||||
if got := claimStrSlice(claims, "roles"); len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
|
||||||
t.Errorf("claimStrSlice(roles) = %v, want [a b]", got)
|
|
||||||
}
|
|
||||||
if got := claimStrSlice(claims, "empty"); len(got) != 0 {
|
|
||||||
t.Errorf("claimStrSlice(empty) = %v, want empty", got)
|
|
||||||
}
|
|
||||||
if got := claimStrSlice(claims, "str"); got != nil {
|
|
||||||
t.Errorf("claimStrSlice(str) = %v, want nil", got)
|
|
||||||
}
|
|
||||||
if got := claimStrSlice(claims, "missing"); got != nil {
|
|
||||||
t.Errorf("claimStrSlice(missing) = %v, want nil", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -33,8 +33,8 @@ func NewRouter(healthChecker *HealthChecker, dispatcher *serviceadapter.Dispatch
|
|||||||
// ServeHTTP implements http.Handler.
|
// ServeHTTP implements http.Handler.
|
||||||
// Priority order:
|
// Priority order:
|
||||||
// 1. /healthz and /readyz to health handlers
|
// 1. /healthz and /readyz to health handlers
|
||||||
// 2. X-Service header to ServiceAdapter dispatcher (phase 8) - PREFERRED routing method
|
// 2. X-Service header to ServiceAdapter dispatcher (phase 8)
|
||||||
// 3. /workflow* to temporal handler - DEPRECATED: use X-Service: workflow instead
|
// 3. /workflow* to temporal handler
|
||||||
// 4. All other paths to upstream handler (phase 0-7)
|
// 4. All other paths to upstream handler (phase 0-7)
|
||||||
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||||
// Health endpoints first
|
// Health endpoints first
|
||||||
@@ -48,8 +48,6 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// X-Service (ServiceAdapter) routing - checked before path-based routing
|
// X-Service (ServiceAdapter) routing - checked before path-based routing
|
||||||
// PREFERRED: All service routing should use X-Service header pattern for consistency,
|
|
||||||
// auth enforcement, and resource-based access control.
|
|
||||||
if req.Header.Get("X-Service") != "" {
|
if req.Header.Get("X-Service") != "" {
|
||||||
if r.dispatcher != nil {
|
if r.dispatcher != nil {
|
||||||
r.dispatcher.Dispatch(w, req)
|
r.dispatcher.Dispatch(w, req)
|
||||||
@@ -58,8 +56,6 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Workflow endpoints
|
// Workflow endpoints
|
||||||
// DEPRECATED: Path-based /workflow routing is legacy.
|
|
||||||
// New clients should use X-Service: workflow header instead for consistent auth.
|
|
||||||
switch req.URL.Path {
|
switch req.URL.Path {
|
||||||
case "/workflow", "/workflow/health", "/workflow/metrics":
|
case "/workflow", "/workflow/health", "/workflow/metrics":
|
||||||
r.temporalHandler.ServeHTTP(w, req)
|
r.temporalHandler.ServeHTTP(w, req)
|
||||||
|
|||||||
+100
-123
@@ -10,36 +10,46 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/net/http2"
|
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/identity"
|
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/problem"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/problem"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Dispatcher routes X-Service requests to upstreams.
|
// Dispatcher routes X-Service requests to upstreams.
|
||||||
|
// Auth per service:
|
||||||
|
// SQS: Gateway validates JWT (kmsvc code unverified)
|
||||||
|
// MinIO, Temporal: Native JWT support (dumb pipe pass-through)
|
||||||
|
// Memory, IAM: Services validate JWTs themselves
|
||||||
type Dispatcher struct {
|
type Dispatcher struct {
|
||||||
registry *Registry
|
registry *Registry
|
||||||
jwtValidator *auth.Validator
|
sqsJWTAuth *auth.Validator
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDispatcher creates a dispatcher with a shared multi-issuer JWT validator.
|
// NewDispatcher creates a new service adapter dispatcher.
|
||||||
// Pass nil to disable auth enforcement (all requests pass through).
|
func NewDispatcher(registry *Registry) *Dispatcher {
|
||||||
func NewDispatcher(registry *Registry, jwtValidator *auth.Validator) *Dispatcher {
|
// Create JWT validator for SQS
|
||||||
|
// Issuer and JWKS URL should match Authentik application config
|
||||||
|
sqsValidator := auth.NewValidator(
|
||||||
|
"https://authentik.riotpiao.com/application/o/sqs/",
|
||||||
|
"sqs",
|
||||||
|
"https://authentik.riotpiao.com/application/o/sqs/jwks/",
|
||||||
|
)
|
||||||
|
|
||||||
return &Dispatcher{
|
return &Dispatcher{
|
||||||
registry: registry,
|
registry: registry,
|
||||||
jwtValidator: jwtValidator,
|
sqsJWTAuth: sqsValidator,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matches returns true if the request has an X-Service header.
|
// Matches returns true if the request should be dispatched based on X-Service header.
|
||||||
func (d *Dispatcher) Matches(r *http.Request) bool {
|
func (d *Dispatcher) Matches(r *http.Request) bool {
|
||||||
return r.Header.Get("X-Service") != ""
|
return r.Header.Get("X-Service") != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispatch routes a request to the appropriate adapter.
|
// Dispatch routes a request to the appropriate adapter.
|
||||||
|
// Returns a problem document if the adapter or resource is not found.
|
||||||
func (d *Dispatcher) Dispatch(w http.ResponseWriter, r *http.Request) {
|
func (d *Dispatcher) Dispatch(w http.ResponseWriter, r *http.Request) {
|
||||||
serviceName := r.Header.Get("X-Service")
|
serviceName := r.Header.Get("X-Service")
|
||||||
if serviceName == "" {
|
if serviceName == "" {
|
||||||
@@ -47,117 +57,102 @@ func (d *Dispatcher) Dispatch(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Look up service adapter
|
||||||
adapter := d.registry.Get(serviceName)
|
adapter := d.registry.Get(serviceName)
|
||||||
if adapter == nil {
|
if adapter == nil {
|
||||||
d.writeError(w, problem.NotFound(fmt.Sprintf("service '%s' not found", serviceName)))
|
p := problem.NotFound(fmt.Sprintf("service '%s' not found", serviceName))
|
||||||
|
_ = p.Write(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get resource and method from request
|
||||||
resourceName := r.Header.Get("X-Resource")
|
resourceName := r.Header.Get("X-Resource")
|
||||||
if resourceName == "" {
|
if resourceName == "" {
|
||||||
d.writeError(w, problem.BadRequest("X-Resource header required"))
|
d.writeError(w, problem.BadRequest("X-Resource header required"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resource := findResource(adapter, resourceName)
|
// Find resource
|
||||||
|
var resource *Resource
|
||||||
|
for i := range adapter.Spec.Resources {
|
||||||
|
if adapter.Spec.Resources[i].Name == resourceName {
|
||||||
|
resource = &adapter.Spec.Resources[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if resource == nil {
|
if resource == nil {
|
||||||
d.writeError(w, problem.NotFound(
|
p := problem.NotFound(fmt.Sprintf("resource '%s' not found in service '%s'", resourceName, serviceName))
|
||||||
fmt.Sprintf("resource '%s' not found in service '%s'", resourceName, serviceName)))
|
_ = p.Write(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
method := findMethod(resource, r.Method)
|
// Find method matching HTTP verb
|
||||||
|
var method *Method
|
||||||
|
for i := range resource.Methods {
|
||||||
|
if resource.Methods[i].Verb == r.Method {
|
||||||
|
method = &resource.Methods[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if method == nil {
|
if method == nil {
|
||||||
d.writeError(w, problem.NotFound(
|
p := problem.NotFound(fmt.Sprintf("method %s not defined for resource '%s'", r.Method, resourceName))
|
||||||
fmt.Sprintf("method %s not defined for resource '%s'", r.Method, resourceName)))
|
_ = p.Write(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// JWT auth enforcement for adapters that require it
|
// Gateway-level JWT validation for SQS (code unverified in kmsvc)
|
||||||
if adapter.Spec.Auth.Required && d.jwtValidator != nil {
|
// MinIO, Temporal, Memory, IAM have native JWT support - pass through
|
||||||
if !d.authenticate(w, r, serviceName, method.Verb) {
|
if adapter.Spec.Auth.Required && serviceName == "sqs" {
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
p := problem.NewProblem(http.StatusForbidden, "about:blank#forbidden",
|
||||||
|
"Forbidden", "SQS requires Authorization header")
|
||||||
|
_ = p.Write(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate JWT signature against Authentik JWKS
|
||||||
|
claims, err := d.sqsJWTAuth.ValidateBearerToken(authHeader)
|
||||||
|
if err != nil {
|
||||||
|
p := problem.NewProblem(http.StatusForbidden, "about:blank#forbidden",
|
||||||
|
"Forbidden", fmt.Sprintf("JWT validation failed: %v", err))
|
||||||
|
_ = p.Write(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check required permissions (sqs:read or sqs:write or *)
|
||||||
|
hasPermission := d.sqsJWTAuth.CheckPermissions(claims, "sqs:read", "sqs:write", "*")
|
||||||
|
if !hasPermission {
|
||||||
|
p := problem.NewProblem(http.StatusForbidden, "about:blank#forbidden",
|
||||||
|
"Forbidden", "Insufficient permissions for SQS")
|
||||||
|
_ = p.Write(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Detect protocol from upstream URL scheme
|
||||||
upstreamURL := adapter.Spec.Upstream.URL
|
upstreamURL := adapter.Spec.Upstream.URL
|
||||||
if strings.HasPrefix(upstreamURL, "grpc://") {
|
if strings.HasPrefix(upstreamURL, "grpc://") {
|
||||||
|
// gRPC upstream (Temporal, etc.)
|
||||||
d.dispatchGRPC(w, r, upstreamURL, method, adapter)
|
d.dispatchGRPC(w, r, upstreamURL, method, adapter)
|
||||||
} else {
|
} else {
|
||||||
|
// HTTP upstream (MinIO, Authentik, etc.)
|
||||||
d.dispatchHTTP(w, r, upstreamURL, method, adapter)
|
d.dispatchHTTP(w, r, upstreamURL, method, adapter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// authenticate validates the JWT and checks service-level capability.
|
// dispatchHTTP forwards HTTP requests to upstream, passing Authorization header through.
|
||||||
// Returns false (and writes error response) if auth fails.
|
|
||||||
func (d *Dispatcher) authenticate(w http.ResponseWriter, r *http.Request, serviceName, verb string) bool {
|
|
||||||
authHeader := r.Header.Get("Authorization")
|
|
||||||
if authHeader == "" {
|
|
||||||
d.writeError(w, problem.NewProblem(http.StatusUnauthorized,
|
|
||||||
"about:blank#unauthorized", "Unauthorized",
|
|
||||||
fmt.Sprintf("service '%s' requires Authorization header", serviceName)))
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
claims, err := d.jwtValidator.ValidateBearerToken(authHeader)
|
|
||||||
if err != nil {
|
|
||||||
d.writeError(w, problem.NewProblem(http.StatusForbidden,
|
|
||||||
"about:blank#forbidden", "Forbidden",
|
|
||||||
fmt.Sprintf("JWT validation failed: %v", err)))
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check capability: <service>:read for GET/HEAD, <service>:write for mutating verbs
|
|
||||||
required := capabilityForVerb(serviceName, verb)
|
|
||||||
if !d.jwtValidator.CheckPermissions(claims, required, "*") {
|
|
||||||
d.writeError(w, problem.NewProblem(http.StatusForbidden,
|
|
||||||
"about:blank#insufficient-permissions", "Insufficient Permissions",
|
|
||||||
fmt.Sprintf("required capability: %s", required)))
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inject identity headers for downstream
|
|
||||||
identity.Inject(r, claims)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// capabilityForVerb maps HTTP verbs to <service>:read or <service>:write.
|
|
||||||
func capabilityForVerb(serviceName, verb string) string {
|
|
||||||
switch verb {
|
|
||||||
case "GET", "HEAD", "OPTIONS":
|
|
||||||
return serviceName + ":read"
|
|
||||||
default:
|
|
||||||
return serviceName + ":write"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func findResource(adapter *ServiceAdapter, name string) *Resource {
|
|
||||||
for i := range adapter.Spec.Resources {
|
|
||||||
if adapter.Spec.Resources[i].Name == name {
|
|
||||||
return &adapter.Spec.Resources[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func findMethod(resource *Resource, verb string) *Method {
|
|
||||||
for i := range resource.Methods {
|
|
||||||
if resource.Methods[i].Verb == verb {
|
|
||||||
return &resource.Methods[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Dispatcher) dispatchHTTP(w http.ResponseWriter, r *http.Request, upstreamURL string, method *Method, adapter *ServiceAdapter) {
|
func (d *Dispatcher) dispatchHTTP(w http.ResponseWriter, r *http.Request, upstreamURL string, method *Method, adapter *ServiceAdapter) {
|
||||||
parsedURL, err := url.Parse(upstreamURL)
|
parsedURL, err := url.Parse(upstreamURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
d.writeError(w, problem.NewProblem(http.StatusInternalServerError,
|
d.writeError(w, problem.NewProblem(http.StatusInternalServerError, "about:blank#server-error",
|
||||||
"about:blank#server-error", "Internal Server Error",
|
"Internal Server Error", fmt.Sprintf("invalid upstream URL: %v", err)))
|
||||||
fmt.Sprintf("invalid upstream URL: %v", err)))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create reverse proxy
|
||||||
proxy := httputil.NewSingleHostReverseProxy(parsedURL)
|
proxy := httputil.NewSingleHostReverseProxy(parsedURL)
|
||||||
proxy.Director = func(req *http.Request) {
|
proxy.Director = func(req *http.Request) {
|
||||||
req.URL.Scheme = parsedURL.Scheme
|
req.URL.Scheme = parsedURL.Scheme
|
||||||
@@ -165,12 +160,10 @@ func (d *Dispatcher) dispatchHTTP(w http.ResponseWriter, r *http.Request, upstre
|
|||||||
req.URL.Path = method.UpstreamPath
|
req.URL.Path = method.UpstreamPath
|
||||||
req.RequestURI = ""
|
req.RequestURI = ""
|
||||||
req.Host = parsedURL.Host
|
req.Host = parsedURL.Host
|
||||||
|
// Authorization header passes through unchanged
|
||||||
// Preserve Authorization header for S3 SigV4 and other auth schemes
|
|
||||||
// Note: httputil.ReverseProxy preserves most headers automatically,
|
|
||||||
// but we need to ensure Authorization isn't lost when overriding Director
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set timeout
|
||||||
timeout := adapter.Spec.Upstream.TimeoutSeconds
|
timeout := adapter.Spec.Upstream.TimeoutSeconds
|
||||||
if timeout <= 0 {
|
if timeout <= 0 {
|
||||||
timeout = 30
|
timeout = 30
|
||||||
@@ -180,25 +173,29 @@ func (d *Dispatcher) dispatchHTTP(w http.ResponseWriter, r *http.Request, upstre
|
|||||||
TLSHandshakeTimeout: time.Duration(timeout) * time.Second,
|
TLSHandshakeTimeout: time.Duration(timeout) * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Forward the request
|
||||||
proxy.ServeHTTP(w, r)
|
proxy.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dispatchGRPC forwards gRPC requests to upstream.
|
||||||
|
// gRPC URL format: grpc://host:port
|
||||||
func (d *Dispatcher) dispatchGRPC(w http.ResponseWriter, r *http.Request, upstreamURL string, method *Method, adapter *ServiceAdapter) {
|
func (d *Dispatcher) dispatchGRPC(w http.ResponseWriter, r *http.Request, upstreamURL string, method *Method, adapter *ServiceAdapter) {
|
||||||
|
// Extract host:port from grpc://host:port
|
||||||
host := strings.TrimPrefix(upstreamURL, "grpc://")
|
host := strings.TrimPrefix(upstreamURL, "grpc://")
|
||||||
if host == upstreamURL {
|
if host == upstreamURL {
|
||||||
d.writeError(w, problem.NewProblem(http.StatusInternalServerError,
|
d.writeError(w, problem.NewProblem(http.StatusInternalServerError, "about:blank#server-error",
|
||||||
"about:blank#server-error", "Internal Server Error",
|
"Internal Server Error", "invalid gRPC URL format"))
|
||||||
"invalid gRPC URL format"))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate that this is a gRPC request
|
||||||
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc") {
|
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc") {
|
||||||
d.writeError(w, problem.NewProblem(http.StatusBadRequest,
|
d.writeError(w, problem.NewProblem(http.StatusBadRequest, "about:blank#bad-request",
|
||||||
"about:blank#bad-request", "Bad Request",
|
"Bad Request", "gRPC service requires application/grpc content-type"))
|
||||||
"gRPC service requires application/grpc content-type"))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set timeout
|
||||||
timeout := adapter.Spec.Upstream.TimeoutSeconds
|
timeout := adapter.Spec.Upstream.TimeoutSeconds
|
||||||
if timeout <= 0 {
|
if timeout <= 0 {
|
||||||
timeout = 30
|
timeout = 30
|
||||||
@@ -207,45 +204,25 @@ func (d *Dispatcher) dispatchGRPC(w http.ResponseWriter, r *http.Request, upstre
|
|||||||
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(timeout)*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(timeout)*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
// Dial gRPC upstream
|
||||||
conn, err := grpc.DialContext(ctx, host,
|
conn, err := grpc.DialContext(ctx, host,
|
||||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||||
grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(100*1024*1024)),
|
grpc.WithDefaultCallOptions(
|
||||||
|
grpc.MaxCallRecvMsgSize(100 * 1024 * 1024), // 100MB
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
d.writeError(w, problem.NewProblem(http.StatusBadGateway,
|
d.writeError(w, problem.NewProblem(http.StatusBadGateway, "about:blank#bad-gateway",
|
||||||
"about:blank#bad-gateway", "Bad Gateway",
|
"Bad Gateway", fmt.Sprintf("failed to dial gRPC upstream: %v", err)))
|
||||||
fmt.Sprintf("failed to dial gRPC upstream: %v", err)))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
// Create HTTP/2 reverse proxy for gRPC
|
// Forward gRPC request
|
||||||
// gRPC uses HTTP/2 protocol, so we need an HTTP/2-capable transport
|
// Note: Full gRPC forwarding requires grpcproxy or custom middleware.
|
||||||
upstreamURLObj := &url.URL{
|
// For now, return unimplemented (Temporal support coming in Phase 9)
|
||||||
Scheme: "http",
|
d.writeError(w, problem.NewProblem(http.StatusNotImplemented, "about:blank#not-implemented",
|
||||||
Host: host,
|
"Not Implemented", "gRPC forwarding not yet implemented - use in-cluster gRPC clients directly"))
|
||||||
}
|
|
||||||
|
|
||||||
proxy := httputil.NewSingleHostReverseProxy(upstreamURLObj)
|
|
||||||
proxy.Director = func(req *http.Request) {
|
|
||||||
req.URL.Scheme = "http"
|
|
||||||
req.URL.Host = host
|
|
||||||
req.URL.Path = method.UpstreamPath
|
|
||||||
req.RequestURI = ""
|
|
||||||
req.Host = host
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create HTTP/2 client transport for gRPC calls
|
|
||||||
// gRPC requires HTTP/2 for proper message framing
|
|
||||||
h2transport := &http2.Transport{
|
|
||||||
AllowHTTP: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the transport on the proxy
|
|
||||||
proxy.Transport = h2transport
|
|
||||||
|
|
||||||
// Serve the request through the proxy
|
|
||||||
proxy.ServeHTTP(w, r)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Dispatcher) writeError(w http.ResponseWriter, p *problem.Problem) {
|
func (d *Dispatcher) writeError(w http.ResponseWriter, p *problem.Problem) {
|
||||||
|
|||||||
@@ -1,265 +0,0 @@
|
|||||||
package serviceadapter
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/identity"
|
|
||||||
)
|
|
||||||
|
|
||||||
// stubValidator implements the minimum interface for testing auth.
|
|
||||||
// Real auth.Validator needs JWKS — we test the dispatcher logic, not JWT crypto.
|
|
||||||
|
|
||||||
func newTestRegistry(adapters ...ServiceAdapter) *Registry {
|
|
||||||
r := NewRegistry(nil)
|
|
||||||
for i := range adapters {
|
|
||||||
_ = r.Add(&adapters[i])
|
|
||||||
}
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
func sqsAdapter(authRequired bool) ServiceAdapter {
|
|
||||||
return ServiceAdapter{
|
|
||||||
Name: "sqs",
|
|
||||||
ServiceName: "sqs",
|
|
||||||
Spec: Spec{
|
|
||||||
ServiceName: "sqs",
|
|
||||||
Upstream: Upstream{URL: "http://localhost:9999", TimeoutSeconds: 5},
|
|
||||||
Auth: Auth{Required: authRequired},
|
|
||||||
Resources: []Resource{
|
|
||||||
{
|
|
||||||
Name: "list-queues",
|
|
||||||
Methods: []Method{
|
|
||||||
{Verb: "GET", UpstreamPath: "/sqs/queues"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "send-message",
|
|
||||||
Methods: []Method{
|
|
||||||
{Verb: "POST", UpstreamPath: "/sqs/send"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func memoryAdapter() ServiceAdapter {
|
|
||||||
return ServiceAdapter{
|
|
||||||
Name: "memory",
|
|
||||||
ServiceName: "memory",
|
|
||||||
Spec: Spec{
|
|
||||||
ServiceName: "memory",
|
|
||||||
Upstream: Upstream{URL: "http://localhost:8888", TimeoutSeconds: 5},
|
|
||||||
Auth: Auth{Required: false},
|
|
||||||
Resources: []Resource{
|
|
||||||
{
|
|
||||||
Name: "skills",
|
|
||||||
Methods: []Method{
|
|
||||||
{Verb: "GET", UpstreamPath: "/memory/skills"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDispatch_MissingXService(t *testing.T) {
|
|
||||||
d := NewDispatcher(newTestRegistry(), nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r := httptest.NewRequest("GET", "/", nil)
|
|
||||||
|
|
||||||
d.Dispatch(w, r)
|
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
|
||||||
t.Errorf("expected 400, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDispatch_UnknownService(t *testing.T) {
|
|
||||||
d := NewDispatcher(newTestRegistry(), nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r := httptest.NewRequest("GET", "/", nil)
|
|
||||||
r.Header.Set("X-Service", "nonexistent")
|
|
||||||
r.Header.Set("X-Resource", "foo")
|
|
||||||
|
|
||||||
d.Dispatch(w, r)
|
|
||||||
|
|
||||||
if w.Code != http.StatusNotFound {
|
|
||||||
t.Errorf("expected 404, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDispatch_MissingXResource(t *testing.T) {
|
|
||||||
d := NewDispatcher(newTestRegistry(memoryAdapter()), nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r := httptest.NewRequest("GET", "/", nil)
|
|
||||||
r.Header.Set("X-Service", "memory")
|
|
||||||
|
|
||||||
d.Dispatch(w, r)
|
|
||||||
|
|
||||||
if w.Code != http.StatusBadRequest {
|
|
||||||
t.Errorf("expected 400, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDispatch_UnknownResource(t *testing.T) {
|
|
||||||
d := NewDispatcher(newTestRegistry(memoryAdapter()), nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r := httptest.NewRequest("GET", "/", nil)
|
|
||||||
r.Header.Set("X-Service", "memory")
|
|
||||||
r.Header.Set("X-Resource", "nonexistent")
|
|
||||||
|
|
||||||
d.Dispatch(w, r)
|
|
||||||
|
|
||||||
if w.Code != http.StatusNotFound {
|
|
||||||
t.Errorf("expected 404, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDispatch_WrongHTTPVerb(t *testing.T) {
|
|
||||||
d := NewDispatcher(newTestRegistry(memoryAdapter()), nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r := httptest.NewRequest("DELETE", "/", nil)
|
|
||||||
r.Header.Set("X-Service", "memory")
|
|
||||||
r.Header.Set("X-Resource", "skills")
|
|
||||||
|
|
||||||
d.Dispatch(w, r)
|
|
||||||
|
|
||||||
if w.Code != http.StatusNotFound {
|
|
||||||
t.Errorf("expected 404, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDispatch_AuthRequired_NoToken(t *testing.T) {
|
|
||||||
// Use nil validator — auth required but no validator means 401
|
|
||||||
// Actually with nil validator, auth is skipped. Use a real scenario.
|
|
||||||
// We need a mock validator. For now test that auth.Required=false passes through.
|
|
||||||
// The real auth test needs the full JWKS setup which is an integration test.
|
|
||||||
|
|
||||||
// Test: auth required, no validator configured = passes through (defense in depth via NetworkPolicy)
|
|
||||||
d := NewDispatcher(newTestRegistry(sqsAdapter(true)), nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r := httptest.NewRequest("GET", "/", nil)
|
|
||||||
r.Header.Set("X-Service", "sqs")
|
|
||||||
r.Header.Set("X-Resource", "list-queues")
|
|
||||||
|
|
||||||
d.Dispatch(w, r)
|
|
||||||
|
|
||||||
// With nil validator, auth check is skipped — request reaches upstream (which will fail since localhost:9999 is down)
|
|
||||||
// The key assertion: it did NOT return 401/403, it tried to proxy
|
|
||||||
if w.Code == http.StatusUnauthorized || w.Code == http.StatusForbidden {
|
|
||||||
t.Errorf("expected proxy attempt (not auth rejection), got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDispatch_AuthNotRequired_NoToken(t *testing.T) {
|
|
||||||
// Start a test upstream
|
|
||||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{"path": r.URL.Path})
|
|
||||||
}))
|
|
||||||
defer upstream.Close()
|
|
||||||
|
|
||||||
adapter := memoryAdapter()
|
|
||||||
adapter.Spec.Upstream.URL = upstream.URL
|
|
||||||
|
|
||||||
d := NewDispatcher(newTestRegistry(adapter), nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r := httptest.NewRequest("GET", "/", nil)
|
|
||||||
r.Header.Set("X-Service", "memory")
|
|
||||||
r.Header.Set("X-Resource", "skills")
|
|
||||||
|
|
||||||
d.Dispatch(w, r)
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Errorf("expected 200, got %d", w.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body map[string]string
|
|
||||||
json.NewDecoder(w.Body).Decode(&body)
|
|
||||||
if body["path"] != "/memory/skills" {
|
|
||||||
t.Errorf("expected upstream path /memory/skills, got %s", body["path"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDispatch_PassThroughHeaders(t *testing.T) {
|
|
||||||
var receivedAuth string
|
|
||||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
receivedAuth = r.Header.Get("Authorization")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
defer upstream.Close()
|
|
||||||
|
|
||||||
adapter := memoryAdapter()
|
|
||||||
adapter.Spec.Upstream.URL = upstream.URL
|
|
||||||
|
|
||||||
d := NewDispatcher(newTestRegistry(adapter), nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r := httptest.NewRequest("GET", "/", nil)
|
|
||||||
r.Header.Set("X-Service", "memory")
|
|
||||||
r.Header.Set("X-Resource", "skills")
|
|
||||||
r.Header.Set("Authorization", "Bearer some-jwt")
|
|
||||||
|
|
||||||
d.Dispatch(w, r)
|
|
||||||
|
|
||||||
if receivedAuth != "Bearer some-jwt" {
|
|
||||||
t.Errorf("Authorization header not passed through, got %q", receivedAuth)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCapabilityForVerb(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
service string
|
|
||||||
verb string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"sqs", "GET", "sqs:read"},
|
|
||||||
{"sqs", "HEAD", "sqs:read"},
|
|
||||||
{"sqs", "OPTIONS", "sqs:read"},
|
|
||||||
{"sqs", "POST", "sqs:write"},
|
|
||||||
{"sqs", "PUT", "sqs:write"},
|
|
||||||
{"sqs", "DELETE", "sqs:write"},
|
|
||||||
{"sqs", "PATCH", "sqs:write"},
|
|
||||||
{"memory", "GET", "memory:read"},
|
|
||||||
{"memory", "POST", "memory:write"},
|
|
||||||
{"s3", "GET", "s3:read"},
|
|
||||||
{"s3", "PUT", "s3:write"},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
got := capabilityForVerb(tt.service, tt.verb)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("capabilityForVerb(%s, %s) = %s, want %s", tt.service, tt.verb, got, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDispatch_IdentityHeadersNotSet_WhenNoAuth(t *testing.T) {
|
|
||||||
var gotUser, gotVerified string
|
|
||||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
gotUser = r.Header.Get(identity.HeaderUser)
|
|
||||||
gotVerified = r.Header.Get(identity.HeaderAuthVerified)
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
defer upstream.Close()
|
|
||||||
|
|
||||||
adapter := memoryAdapter()
|
|
||||||
adapter.Spec.Upstream.URL = upstream.URL
|
|
||||||
|
|
||||||
d := NewDispatcher(newTestRegistry(adapter), nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r := httptest.NewRequest("GET", "/", nil)
|
|
||||||
r.Header.Set("X-Service", "memory")
|
|
||||||
r.Header.Set("X-Resource", "skills")
|
|
||||||
|
|
||||||
d.Dispatch(w, r)
|
|
||||||
|
|
||||||
if gotUser != "" {
|
|
||||||
t.Errorf("X-Forwarded-User should not be set without auth, got %q", gotUser)
|
|
||||||
}
|
|
||||||
if gotVerified != "" {
|
|
||||||
t.Errorf("X-Auth-Verified should not be set without auth, got %q", gotVerified)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+147
-16
File diff suppressed because one or more lines are too long
@@ -57,12 +57,6 @@ spec:
|
|||||||
value: "0.0.0.0:8080"
|
value: "0.0.0.0:8080"
|
||||||
- name: CONFIG_PATH
|
- name: CONFIG_PATH
|
||||||
value: "/etc/gateway/config.yaml"
|
value: "/etc/gateway/config.yaml"
|
||||||
- name: AUTH_CLIENT_SECRET
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: api-gw-client-secret
|
|
||||||
key: client-secret
|
|
||||||
optional: true
|
|
||||||
- name: SHUTDOWN_TIMEOUT
|
- name: SHUTDOWN_TIMEOUT
|
||||||
value: "5m"
|
value: "5m"
|
||||||
- name: LOG_LEVEL
|
- name: LOG_LEVEL
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: api-gateway-config
|
||||||
|
namespace: api
|
||||||
|
labels:
|
||||||
|
app: api-gateway
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
config.yaml: |
|
||||||
|
auth:
|
||||||
|
enabled: true
|
||||||
|
issuer: "https://authentik.riotpiao.com/application/o/api-gw/"
|
||||||
|
audience: "api-gw"
|
||||||
|
jwksUrl: "http://authentik-server.iam.svc.cluster.local/application/o/api-gw/jwks/"
|
||||||
|
requiredCapability: "llm:inference"
|
||||||
|
routes: []
|
||||||
|
models:
|
||||||
|
- name: "reasoning"
|
||||||
|
address: "reasoning-predictor.llm-serving:80"
|
||||||
|
path: "/v1/chat/completions"
|
||||||
|
- name: "ornith:35b"
|
||||||
|
address: "ornith-predictor.llm-serving:80"
|
||||||
|
path: "/v1/chat/completions"
|
||||||
|
- name: "qwen2.5:3b-instruct"
|
||||||
|
address: "ornith-predictor.llm-serving:80"
|
||||||
|
path: "/v1/chat/completions"
|
||||||
|
- name: "nomic-ai/nomic-embed-text-v2-moe"
|
||||||
|
address: "embeddings-predictor.llm-serving:80"
|
||||||
|
path: "/v1/embeddings"
|
||||||
|
- name: "BAAI/bge-reranker-base"
|
||||||
|
address: "reranker-predictor.llm-serving:80"
|
||||||
|
path: "/v1/rerank"
|
||||||
|
adapters:
|
||||||
|
- serviceName: sqs
|
||||||
|
upstream:
|
||||||
|
url: http://management-service.sqs.svc.cluster.local:9090
|
||||||
|
timeoutSeconds: 30
|
||||||
|
auth:
|
||||||
|
required: true
|
||||||
|
resources:
|
||||||
|
- name: send-message
|
||||||
|
methods:
|
||||||
|
- verb: POST
|
||||||
|
upstreamPath: /sqs/send
|
||||||
|
- name: receive-message
|
||||||
|
methods:
|
||||||
|
- verb: POST
|
||||||
|
upstreamPath: /sqs/receive
|
||||||
|
- name: list-queues
|
||||||
|
methods:
|
||||||
|
- verb: GET
|
||||||
|
upstreamPath: /sqs/queues
|
||||||
|
- serviceName: workflow
|
||||||
|
upstream:
|
||||||
|
url: grpc://temporal-frontend.temporal.svc.cluster.local:7233
|
||||||
|
timeoutSeconds: 60
|
||||||
|
auth:
|
||||||
|
required: false
|
||||||
|
resources:
|
||||||
|
- name: execute
|
||||||
|
methods:
|
||||||
|
- verb: POST
|
||||||
|
upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/ExecuteWorkflow
|
||||||
|
- name: describe
|
||||||
|
methods:
|
||||||
|
- verb: GET
|
||||||
|
upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/DescribeWorkflowExecution
|
||||||
|
- name: list
|
||||||
|
methods:
|
||||||
|
- verb: GET
|
||||||
|
upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/ListWorkflowExecutions
|
||||||
|
- serviceName: memory
|
||||||
|
upstream:
|
||||||
|
url: http://poimen-memory.poimen.svc.cluster.local:8080
|
||||||
|
timeoutSeconds: 30
|
||||||
|
auth:
|
||||||
|
required: false
|
||||||
|
resources:
|
||||||
|
- name: query
|
||||||
|
methods:
|
||||||
|
- verb: POST
|
||||||
|
upstreamPath: /memory/query
|
||||||
|
- name: ingest
|
||||||
|
methods:
|
||||||
|
- verb: POST
|
||||||
|
upstreamPath: /memory/ingest
|
||||||
|
- name: skills
|
||||||
|
methods:
|
||||||
|
- verb: GET
|
||||||
|
upstreamPath: /memory/skills
|
||||||
|
- serviceName: s3
|
||||||
|
upstream:
|
||||||
|
url: http://minio.storage.svc.cluster.local:9000
|
||||||
|
timeoutSeconds: 30
|
||||||
|
auth:
|
||||||
|
required: false
|
||||||
|
resources:
|
||||||
|
- name: list-objects
|
||||||
|
methods:
|
||||||
|
- verb: GET
|
||||||
|
upstreamPath: /
|
||||||
|
- name: get-object
|
||||||
|
methods:
|
||||||
|
- verb: GET
|
||||||
|
upstreamPath: /
|
||||||
|
- name: put-object
|
||||||
|
methods:
|
||||||
|
- verb: PUT
|
||||||
|
upstreamPath: /
|
||||||
|
- serviceName: iam
|
||||||
|
upstream:
|
||||||
|
url: http://authentik-server.iam.svc.cluster.local:80
|
||||||
|
timeoutSeconds: 30
|
||||||
|
auth:
|
||||||
|
required: false
|
||||||
|
resources:
|
||||||
|
- name: list-roles
|
||||||
|
methods:
|
||||||
|
- verb: GET
|
||||||
|
upstreamPath: /api/v3/roles
|
||||||
|
- name: list-users
|
||||||
|
methods:
|
||||||
|
- verb: GET
|
||||||
|
upstreamPath: /api/v3/users
|
||||||
|
- name: create-role
|
||||||
|
methods:
|
||||||
|
- verb: POST
|
||||||
|
upstreamPath: /api/v3/roles
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: Secret
|
|
||||||
metadata:
|
|
||||||
name: api-gateway-config
|
|
||||||
namespace: api
|
|
||||||
labels:
|
|
||||||
app: api-gateway
|
|
||||||
type: Opaque
|
|
||||||
stringData:
|
|
||||||
config.yaml: "# PRODUCTION GATEWAY CONFIGURATION\n# ==========================================\n# All upstream services MUST use Kubernetes internal service DNS names\n# Format: <service>.<namespace>.svc.cluster.local\n# \n# This ensures:\n# - Communication within cluster network only (no external IP exposure)\n# - Pod-to-pod service discovery via internal DNS\n# - Security policy enforcement at network level\n# - Service-level load balancing via kube-proxy\n#\n# Routing Pattern:\n# PREFERRED: X-Service header routing (e.g., X-Service: workflow)\n# Legacy: Path-based routing (e.g., /workflow) - being deprecated\n#\nauth:\n enabled: true\n issuer: \"https://authentik.riotpiao.com/application/o/api-gw/\"\n audience: \"api-gw\"\n jwksUrl: \"http://authentik-server.iam.svc.cluster.local/application/o/api-gw/jwks/\"\n requiredCapability: \"llm:inference\"\n tokenUrl: \"http://authentik-server.iam.svc.cluster.local/application/o/token/\"\n clientId: \"api-gw\"\nroutes: []\nmodels:\n# All model services use internal Kubernetes DNS (llm-serving namespace)\n- name: \"reasoning\"\n address: \"reasoning-predictor.llm-serving.svc.cluster.local:80\"\n path: \"/v1/chat/completions\"\n- name: \"ornith:35b\"\n address: \"ornith-predictor.llm-serving.svc.cluster.local:80\"\n path: \"/v1/chat/completions\"\n- name: \"qwen2.5:3b-instruct\"\n address: \"qwen-cpu.llm-serving.svc.cluster.local:80\"\n path: \"/v1/chat/completions\"\n- name: \"nomic-ai/nomic-embed-text-v2-moe\"\n address: \"embeddings-predictor.llm-serving.svc.cluster.local:80\"\n path: \"/v1/embeddings\"\n- name: \"BAAI/bge-reranker-base\"\n address: \"reranker-predictor.llm-serving.svc.cluster.local:80\"\n path: \"/v1/rerank\"\nadapters:\n- serviceName: sqs\n upstream:\n url: http://management-service.sqs.svc.cluster.local:9090\n timeoutSeconds: 30\n auth:\n required: true\n resources:\n - name: send-message\n methods:\n - verb: POST\n upstreamPath: /sqs/send\n - name: receive-message\n methods:\n - verb: POST\n upstreamPath: /sqs/receive\n - name: list-queues\n methods:\n - verb: GET\n upstreamPath: /sqs/queues\n- serviceName: workflow\n upstream:\n url: grpc://temporal-frontend.temporal.svc.cluster.local:7233\n timeoutSeconds: 60\n auth:\n required: false\n resources:\n - name: execute\n methods:\n - verb: POST\n upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/ExecuteWorkflow\n - name: describe\n methods:\n - verb: GET\n upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/DescribeWorkflowExecution\n - name: list\n methods:\n - verb: GET\n upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/ListWorkflowExecutions\n- serviceName: memory\n upstream:\n url: http://poimen-memory.poimen.svc.cluster.local:8080\n timeoutSeconds: 30\n auth:\n required: false\n resources:\n - name: query\n methods:\n - verb: POST\n upstreamPath: /memory/query\n - name: ingest\n methods:\n - verb: POST\n upstreamPath: /memory/ingest\n - name: skills\n methods:\n - verb: GET\n upstreamPath: /memory/skills\n- serviceName: s3\n upstream:\n url: http://minio.storage.svc.cluster.local:80\n timeoutSeconds: 30\n auth:\n required: false\n resources:\n - name: list-objects\n methods:\n - verb: GET\n upstreamPath: /\n - name: get-object\n methods:\n - verb: GET\n upstreamPath: /\n - name: put-object\n methods:\n - verb: PUT\n upstreamPath: /\n- serviceName: iam\n upstream:\n url: http://authentik-server.iam.svc.cluster.local:80\n timeoutSeconds: 30\n auth:\n required: false\n resources:\n - name: list-roles\n methods:\n - verb: GET\n upstreamPath: /api/v3/roles\n - name: list-users\n methods:\n - verb: GET\n upstreamPath: /api/v3/users\n - name: create-role\n methods:\n - verb: POST\n upstreamPath: /api/v3/roles\n"
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
apiVersion: batch/v1
|
|
||||||
kind: Job
|
|
||||||
metadata:
|
|
||||||
name: api-gateway-integration-test
|
|
||||||
namespace: api
|
|
||||||
spec:
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: api-gateway
|
|
||||||
managed-by: test
|
|
||||||
role: integration-test
|
|
||||||
spec:
|
|
||||||
serviceAccountName: api-gateway
|
|
||||||
restartPolicy: Never
|
|
||||||
containers:
|
|
||||||
- name: integration-tester
|
|
||||||
image: forgejo.riotpiao.com/rock/api-gateway:latest
|
|
||||||
imagePullPolicy: Always
|
|
||||||
workingDir: /app
|
|
||||||
command:
|
|
||||||
- /bin/sh
|
|
||||||
- -c
|
|
||||||
- |
|
|
||||||
set -e
|
|
||||||
echo "Starting integration tests..."
|
|
||||||
echo "Gateway URL: http://api-gateway:8080"
|
|
||||||
|
|
||||||
# Wait for gateway service to be ready
|
|
||||||
echo "Waiting for gateway service to be ready..."
|
|
||||||
for i in $(seq 1 30); do
|
|
||||||
if curl -s http://api-gateway:8080/healthz > /dev/null 2>&1; then
|
|
||||||
echo "✓ Gateway is ready"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
echo "Waiting for gateway... ($i/30)"
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
# Run integration tests
|
|
||||||
echo "Running integration tests..."
|
|
||||||
go test -v -tags=integration -timeout=5m ./internal/integration/...
|
|
||||||
|
|
||||||
echo "✓ Integration tests completed"
|
|
||||||
env:
|
|
||||||
- name: GATEWAY_URL
|
|
||||||
value: "http://api-gateway:8080"
|
|
||||||
- name: CI
|
|
||||||
value: "true"
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: 250m
|
|
||||||
memory: 512Mi
|
|
||||||
limits:
|
|
||||||
cpu: 500m
|
|
||||||
memory: 1Gi
|
|
||||||
securityContext:
|
|
||||||
runAsNonRoot: true
|
|
||||||
runAsUser: 65532
|
|
||||||
allowPrivilegeEscalation: false
|
|
||||||
capabilities:
|
|
||||||
drop:
|
|
||||||
- ALL
|
|
||||||
readOnlyRootFilesystem: true
|
|
||||||
volumeMounts:
|
|
||||||
- name: tmp
|
|
||||||
mountPath: /tmp
|
|
||||||
- name: home
|
|
||||||
mountPath: /home/nonroot
|
|
||||||
volumes:
|
|
||||||
- name: tmp
|
|
||||||
emptyDir: {}
|
|
||||||
- name: home
|
|
||||||
emptyDir: {}
|
|
||||||
imagePullSecrets:
|
|
||||||
- name: regcred
|
|
||||||
backoffLimit: 1
|
|
||||||
@@ -8,7 +8,7 @@ resources:
|
|||||||
- service.yaml
|
- service.yaml
|
||||||
- deployment.yaml
|
- deployment.yaml
|
||||||
- network-policy.yaml
|
- network-policy.yaml
|
||||||
- gateway-config-secret.yaml
|
- gateway-config-secret.enc.yaml
|
||||||
|
|
||||||
# The deployed image tag lives here and nowhere else. CI publishes
|
# The deployed image tag lives here and nowhere else. CI publishes
|
||||||
# forgejo.riotpiao.com/rock/api-gateway:<commit-sha> and tags it as :latest on main.
|
# forgejo.riotpiao.com/rock/api-gateway:<commit-sha> and tags it as :latest on main.
|
||||||
|
|||||||
@@ -123,14 +123,10 @@ spec:
|
|||||||
- protocol: TCP
|
- protocol: TCP
|
||||||
port: 8080
|
port: 8080
|
||||||
# Allow to MinIO (S3-compatible storage)
|
# Allow to MinIO (S3-compatible storage)
|
||||||
# Service `minio` listens on port 80 (targetPort 9000).
|
|
||||||
# Headless `minio-cluster-hl` is 9000. Allow both.
|
|
||||||
- to:
|
- to:
|
||||||
- namespaceSelector:
|
- namespaceSelector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
kubernetes.io/metadata.name: storage
|
kubernetes.io/metadata.name: storage
|
||||||
ports:
|
ports:
|
||||||
- protocol: TCP
|
|
||||||
port: 80
|
|
||||||
- protocol: TCP
|
- protocol: TCP
|
||||||
port: 9000
|
port: 9000
|
||||||
|
|||||||
Reference in New Issue
Block a user