MEMO-041: Authentication and Authorization Integration Testing Guide
Overview
This memo documents the comprehensive end-to-end authentication and authorization integration testing infrastructure for Prism, including JWT validation, namespace-based authorization, and auth context pass-through to pattern runners.
Implemented in PR #33: Complete E2E JWT Authentication and Authorization Integration
Background
The authentication and authorization integration enables:
- JWT token validation with Dex OIDC provider
- Namespace-based read/write permissions
- Auth context pass-through to pattern runners via HTTP/2 headers
- Zero-boilerplate SDK for pattern runners to extract auth context
- Distributed tracing with unique request trace IDs
- Audit logging with user identity at pattern runner level
Test Architecture
The auth integration test (tests/testing/auth_integration_test.go) provides end-to-end testing using:
- Dex - OIDC provider for JWT token issuance
- prism-proxy - Rust proxy with JWT validation and authorization
- keyvalue-runner - MemStore-backed KeyValue service with auth SDK
- Test client - Embedded Go client that acquires tokens and makes requests
Architecture Diagram
┌─────────────┐
│ Test Client │ (Go test code - acquires JWT, makes requests)
└──────┬──────┘
│
│ Bearer token (JWT)
▼
┌─────────────┐ ┌─────────────┐
│ Dex │◀───────│ Prism Proxy │
│ (OIDC) │ JWKS │ (Rust) │
└─────────────┘ └──────┬──────┘
│
│ Auth context headers
│ (trace-id, user-id, permission)
▼
┌──────────────┐
│ keyvalue- │
│ runner │
│ (MemStore) │
└──────────────┘
Implementation Status
What Works Now ✅
- JWT validation with Dex OIDC provider
- Namespace-based authorization policies
- Auth context pass-through via HTTP/2 HEADERS
- Pattern runner SDK for auth context extraction
- 10 auth test scenarios (token acquisition, permissions, denials)
- Dex testcontainer backend infrastructure
- Zero-copy TCP forwarding with selective HEADERS modification
- Distributed tracing with UUID v4 trace IDs
- Automatic audit logging via gRPC interceptors
Components Implemented
-
Proxy Authentication (
prism-proxy/src/main.rs):AuthContextstruct for user identity and permissionsdetermine_permission()- Maps gRPC methods to Read/Writehandle_connection()- JWT validation and authorizationinject_context_headers()- Modifies HTTP/2 HEADERS frames- Configuration:
PRISM_AUTH_ENABLED,DEX_ISSUER,DEX_CLIENT_ID
-
Authorization Policies (
prism-proxy/src/auth.rs):- team-alpha: dev (read+write), alice (read only)
- team-beta: admin (read+write), alice (read+write), bob (read only)
- shared: everyone (read+write)
-
Pattern Runner SDK (
pkg/plugin/):auth_context.go- AuthContext type and extraction helpersauth_interceptor.go- Automatic logging interceptors- Zero-boilerplate:
ExtractAuthContext(ctx)
-
Test Infrastructure (
tests/testing/):backends/dex.go- Dex testcontainer backendauth_integration_test.go- 10 test scenarios
Running the Tests
Test Command
cd tests/testing
go test -v -run TestAuthIntegration
Test Scenarios Covered
The integration test validates:
- JWT Token Acquisition - Acquire tokens from Dex for test users
- Read Permissions - Verify read-only access to namespaces
- Write Permissions - Verify read+write access to namespaces
- Permission Denied (403) - Verify permission denial scenarios
- Unauthenticated Requests (401) - Verify auth requirement enforcement
- Namespace Isolation - Verify users can't access unauthorized namespaces
- Method-Based Permissions - Get→Read, Set→Write
- Auth Context Pass-Through - Verify headers reach pattern runners
- Trace ID Generation - Verify unique trace IDs per request
- Audit Logging - Verify user identity in pattern runner logs
Test Users
Test users are configured in local-dev/dex/config.yaml:
| Password | User ID | |
|---|---|---|
dev@local.prism | password | 08a8684b-db88-4b73-90a9-3cd1661f5466 |
admin@local.prism | password | 3b241101-e2bb-4255-8caf-4136c566a962 |
alice@example.com | password | 41331323-6f44-45e6-b3b9-2c4b60c02be5 |
bob@example.com | password | 7e8b6c3e-5e37-4f5e-9e0e-3e5f8f8f8f8f |
Auth Context Pass-Through
The proxy injects authentication context headers into every request forwarded to pattern runners. This enables:
- Audit Logging - Log requests with user identity
- Secondary Authorization - Pattern runners can add additional checks
- Distributed Tracing - Correlate requests across services using trace IDs
- User-Specific Behavior - Rate limiting, quotas, per-user metrics
Context Headers
Headers injected by proxy (per-call):
x-prism-trace-id- Unique UUID for request tracingx-prism-user-id- User ID from JWT subject claimx-prism-user-email- User email (if present in JWT)x-prism-namespace- Namespace being accessedx-prism-permission- Permission level granted (readorwrite)x-prism-scopes- OAuth scopes (comma-separated, if available)
Pattern runners receive these headers on every gRPC call and can extract them from the gRPC metadata.
Technical Details
- Per-Call Injection - Stateless operation, unique trace IDs per request
- Zero-Copy Forwarding - Only HEADERS frames modified via HPACK encoding
- HTTP/2 Selective Modification - Data frames pass through untouched
- No Proxy State - Scales horizontally without session management
Using Auth Context in Pattern Runners
The pkg/plugin SDK provides built-in support for extracting and using auth context. Pattern runners get automatic access to user identity, permissions, and tracing information.
Step 1: Add Interceptor to gRPC Server
import "github.com/jrepp/prism-data-layer/pkg/plugin"
grpcServer := grpc.NewServer(
grpc.UnaryInterceptor(plugin.AuthLoggingInterceptor()),
grpc.StreamInterceptor(plugin.AuthStreamInterceptor()),
)
The interceptor automatically logs all requests with auth context.
Step 2: Extract Context in Handlers
func (s *MyService) Set(ctx context.Context, req *pb.SetRequest) (*pb.SetResponse, error) {
// Extract auth context from request
authCtx := plugin.ExtractAuthContext(ctx)
// Log with user identity and trace ID
slog.Info("Set operation",
"key", req.Key,
"trace_id", authCtx.TraceID,
"user_id", authCtx.UserID,
"namespace", authCtx.Namespace,
"permission", authCtx.Permission,
)
// Check permissions (optional - proxy already enforced this)
if !authCtx.HasPermission(plugin.PermissionWrite) {
return &pb.SetResponse{
Success: false,
Error: "insufficient permissions",
}, nil
}
// Your handler logic here...
}
Step 3: Use Helper Methods
authCtx := plugin.ExtractAuthContext(ctx)
// Check authentication status
if authCtx.IsAuthenticated {
// User is authenticated
}
// Check permissions
if authCtx.HasPermission(plugin.PermissionRead) {
// User has read permission
}
if authCtx.HasPermission(plugin.PermissionWrite) {
// User has write permission (includes read)
}
// Check OAuth scopes
if authCtx.HasScope("admin") {
// User has admin scope
}
// Get structured logging fields
slog.Info("operation", authCtx.LogFields()...)
AuthContext API
type AuthContext struct {
TraceID string // Unique trace ID
UserID string // User ID (JWT subject)
UserEmail string // User email
Namespace string // Namespace
Permission string // "read" or "write"
Scopes []string // OAuth scopes
IsAuthenticated bool // Auth status
}
Benefits for Pattern Runners
- ✅ Automatic audit logging with user identity
- ✅ Distributed tracing with unique trace IDs
- ✅ Secondary authorization checks (if needed)
- ✅ User-specific behavior (rate limiting, quotas)
- ✅ Zero-boilerplate - just call
ExtractAuthContext(ctx)
Configuration
Proxy Configuration (Environment Variables)
# Enable authentication
export PRISM_AUTH_ENABLED=true
# Dex OIDC configuration
export DEX_ISSUER=http://localhost:5556/dex
export DEX_CLIENT_ID=prismctl
Authorization Policies
Authorization policies are configured in prism-proxy/src/auth.rs:
pub fn with_test_policies() -> Self {
let mut policies = HashMap::new();
// User IDs from Dex config
const DEV_USER_ID: &str = "08a8684b-db88-4b73-90a9-3cd1661f5466";
const ADMIN_USER_ID: &str = "3b241101-e2bb-4255-8caf-4136c566a962";
const ALICE_USER_ID: &str = "41331323-6f44-45e6-b3b9-2c4b60c02be5";
const BOB_USER_ID: &str = "7e8b6c3e-5e37-4f5e-9e0e-3e5f8f8f8f8f";
// team-alpha: dev has read+write, alice has read only
policies.insert("team-alpha".to_string(), NamespacePolicy {
read_users: vec![DEV_USER_ID.to_string(), ALICE_USER_ID.to_string()],
write_users: vec![DEV_USER_ID.to_string()],
});
// team-beta: admin has read+write, alice has read+write, bob has read only
policies.insert("team-beta".to_string(), NamespacePolicy {
read_users: vec![ADMIN_USER_ID.to_string(), ALICE_USER_ID.to_string(), BOB_USER_ID.to_string()],
write_users: vec![ADMIN_USER_ID.to_string(), ALICE_USER_ID.to_string()],
});
// shared: everyone has read+write
policies.insert("shared".to_string(), NamespacePolicy {
read_users: vec![DEV_USER_ID.to_string(), ADMIN_USER_ID.to_string(), ALICE_USER_ID.to_string(), BOB_USER_ID.to_string()],
write_users: vec![DEV_USER_ID.to_string(), ADMIN_USER_ID.to_string(), ALICE_USER_ID.to_string(), BOB_USER_ID.to_string()],
});
Self { policies }
}
Comparison to Old E2E Test
Old Approach (tests/e2e/auth-integration/)
- ❌ Docker Compose orchestration (separate process, harder to debug)
- ❌ Custom Dockerfiles for each component
- ❌ Separate client binary
- ❌ Manual service coordination
- ✅ Full auth/authz testing
New Approach (tests/testing/)
- ✅ Testcontainers (runs in Go test process, easier to debug)