diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ec4016..77e3666 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,6 +106,32 @@ jobs: - name: Run replay integration tests run: cargo test --test replay_integration_test + dependency-audit: + name: Dependency Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install Node dependencies + run: npm ci + + - name: Install cargo-audit + run: cargo install cargo-audit --locked + + - name: Run dependency audits + env: + REQUIRE_DEPENDENCY_AUDIT: "true" + run: bash guard-rail-engine/scripts/verification/dependency-audits.sh + engine-runtime-smoke: name: Engine — Runtime Smoke runs-on: ubuntu-latest diff --git a/docs/superpowers/specs/2026-04-28-future-hybrid-platform-system-design.md b/docs/superpowers/specs/2026-04-28-future-hybrid-platform-system-design.md new file mode 100644 index 0000000..c8122b6 --- /dev/null +++ b/docs/superpowers/specs/2026-04-28-future-hybrid-platform-system-design.md @@ -0,0 +1,1725 @@ +# Guard Rail Future Hybrid Platform System Design + +**Date:** 2026-04-28 + +**Status:** Approved design + +## Goal + +Define the future full-platform architecture for Guard Rail as a hybrid enterprise SaaS: a hosted multi-tenant control plane paired with customer-deployed enforcement gateways. + +The design expands beyond the current pilot runtime while preserving its most important boundary: Guard Rail gateways enforce traffic locally, and the hosted platform does not sit in the customer's hot request path. + +## Recommended Approach + +Guard Rail should use a hybrid model: + +- hosted control plane for organizations, users, policy authoring, route management, gateway fleet management, config rollout, metadata audit search, billing, and operational visibility +- customer-deployed data plane for low-latency policy enforcement, upstream forwarding, local audit persistence, and local replay artifacts +- metadata-only hosted audit by default, with full request and response payloads staying in the customer environment unless explicitly exported + +This is the most credible architecture for a security/runtime product because customers can keep internal API traffic and sensitive payloads inside their own network boundary while still receiving a managed SaaS workflow for configuration, visibility, and operations. + +## Scope + +The baseline target is early enterprise SaaS with a documented path to larger scale. + +Initial scale assumptions: + +- 10-100 customer organizations +- 100-1,000 deployed gateways +- 1,000 active users +- 10,000 configured routes +- 100,000 policy versions +- 1k-10k aggregate gateway requests per second, mostly handled outside the hosted control plane +- 50-200 gateway heartbeats per second at peak +- 500-2,000 audit metadata events per second at peak +- 1-5 TB per year of hosted metadata and operational records + +The first version is a single-primary-region SaaS control plane. It should not require global active-active operation, sharded core data, or centralized payload retention from day one. + +## Functional Requirements + +The platform must support: + +- organization and workspace management +- user authentication, SSO, RBAC, and service accounts +- tenant and customer isolation +- route and upstream API configuration +- policy authoring, validation, testing, versioning, and approval +- signed config snapshot generation +- secure gateway registration, credential rotation, and revocation +- gateway config sync and heartbeat reporting +- local request enforcement at gateways +- local audit and replay storage at gateways +- hosted audit metadata ingest and search +- alerts for blocked traffic, gateway health, config drift, and policy rollout failures +- billing and usage metering based on organizations, gateways, routes, and metadata volume +- admin APIs and UI APIs for platform workflows + +## Non-Functional Requirements + +Availability: + +- hosted control plane target: 99.9% +- gateway request enforcement continues during short control-plane outages +- gateway deployments should support multiple replicas behind a customer-owned internal load balancer + +Latency: + +- gateway policy evaluation should add low single-digit millisecond p95 overhead under normal load, excluding upstream latency +- hosted control-plane latency is not part of customer request-path latency + +Consistency: + +- strong consistency for control-plane route, policy, identity, and config snapshot writes +- eventual consistency for gateway rollout and hosted audit metadata ingest +- immutable policy versions, route versions, and config snapshots + +Security: + +- zero-trust gateway registration +- mTLS or signed request authentication for gateway APIs +- signed config snapshots +- encrypted data in transit and at rest +- envelope encryption for customer-sensitive configuration +- least-privilege access and tenant isolation + +Data boundary: + +- hosted control plane stores metadata by default +- full request and response bodies remain customer-local by default +- optional central exports require explicit customer configuration and retention policy + +Operability: + +- structured logs, metrics, traces, alerts, runbooks, migration controls, and disaster recovery procedures +- explicit retention and cleanup for hosted and gateway-local data + +## Out Of Scope For The First Platform Version + +- active-active global control plane +- vendor-hosted hot-path gateway as the default model +- customer self-hosting the full SaaS control plane +- full payload centralization by default +- real-time global analytics over all customer payloads +- custom per-customer database clusters from day one +- Kubernetes-only deployment requirement for gateways + +## Data Model And Storage + +### Storage Choices + +Use Postgres as the hosted control-plane system of record. The domain is relational: organizations, users, roles, gateways, routes, policies, versions, approvals, rollout state, and billing accounts need transactions and referential integrity. + +Use Redis for short-lived cache, distributed locks, idempotency keys, rate limits, and ephemeral sync state. + +Use a durable queue or event bus for asynchronous ingest and background work. Suitable first choices include SQS/SNS, Pub/Sub, Kafka, or Redpanda depending on the hosting environment. + +Use object storage for exported artifacts, generated reports, config snapshot archives, and optional customer-enabled replay exports. Do not store full payload artifacts centrally by default. + +At the gateway, keep local Postgres or customer-approved durable storage for execution audit, replay artifacts, and tamper-evident ledger data. + +### Core Entity Relationships + +```text +organizations + 1 -> many workspaces + 1 -> many users through organization_memberships + 1 -> many gateways + 1 -> many billing_accounts + +users + 1 -> many organization_memberships + 1 -> many audit_actor_events + +roles + 1 -> many role_permissions + many -> many users through organization_memberships + +workspaces + many -> 1 organizations + 1 -> many environments + 1 -> many route_definitions + 1 -> many policy_sets + +environments + many -> 1 workspaces + values: dev, staging, production, custom + +gateway_clusters + many -> 1 organizations + 1 -> many gateways + 1 -> many environment_bindings + +gateways + many -> 1 gateway_clusters + 1 -> many gateway_heartbeats + 1 -> many config_deployments + 1 -> many audit_metadata_events + +route_definitions + many -> 1 workspaces + 1 -> many route_versions + many -> many policy_sets through route_policy_bindings + +policy_sets + many -> 1 workspaces + 1 -> many policy_versions + +config_snapshots + many -> 1 environments + 1 -> many config_snapshot_items + 1 -> many config_deployments + +config_deployments + many -> 1 config_snapshots + many -> 1 gateway_clusters or gateways + +audit_metadata_events + many -> 1 organizations + many -> 1 workspaces + optional many -> 1 gateways + optional route_id, policy_version_id, config_snapshot_id + +replay_exports + many -> 1 organizations + many -> 1 audit_metadata_events + optional object_storage reference + +usage_events + many -> 1 organizations + +billing_accounts + many -> 1 organizations + 1 -> many subscriptions + 1 -> many invoices +``` + +### Hosted Control-Plane Tables + +Representative schema: + +```sql +organizations ( + id uuid primary key, + slug text unique not null, + name text not null, + status text not null, + created_at timestamptz not null, + updated_at timestamptz not null +); + +users ( + id uuid primary key, + email citext unique not null, + display_name text, + status text not null, + created_at timestamptz not null +); + +organization_memberships ( + organization_id uuid references organizations(id), + user_id uuid references users(id), + role_id uuid references roles(id), + status text not null, + created_at timestamptz not null, + primary key (organization_id, user_id) +); + +workspaces ( + id uuid primary key, + organization_id uuid references organizations(id), + name text not null, + slug text not null, + created_at timestamptz not null, + unique (organization_id, slug) +); + +environments ( + id uuid primary key, + workspace_id uuid references workspaces(id), + name text not null, + type text not null, + created_at timestamptz not null, + unique (workspace_id, name) +); + +gateway_clusters ( + id uuid primary key, + organization_id uuid references organizations(id), + name text not null, + deployment_mode text not null, + created_at timestamptz not null +); + +gateways ( + id uuid primary key, + organization_id uuid references organizations(id), + cluster_id uuid references gateway_clusters(id), + name text not null, + status text not null, + public_key text not null, + version text, + last_seen_at timestamptz, + created_at timestamptz not null +); + +route_definitions ( + id uuid primary key, + workspace_id uuid references workspaces(id), + route_key text not null, + status text not null, + created_at timestamptz not null, + unique (workspace_id, route_key) +); + +route_versions ( + id uuid primary key, + route_id uuid references route_definitions(id), + version_number integer not null, + upstream_url_ciphertext bytea not null, + method_allowlist text[] not null, + config_json jsonb not null, + created_by uuid references users(id), + created_at timestamptz not null, + unique (route_id, version_number) +); + +policy_sets ( + id uuid primary key, + workspace_id uuid references workspaces(id), + policy_key text not null, + status text not null, + created_at timestamptz not null, + unique (workspace_id, policy_key) +); + +policy_versions ( + id uuid primary key, + policy_set_id uuid references policy_sets(id), + version_number integer not null, + policy_json jsonb not null, + policy_hash text not null, + created_by uuid references users(id), + created_at timestamptz not null, + unique (policy_set_id, version_number) +); + +config_snapshots ( + id uuid primary key, + environment_id uuid references environments(id), + snapshot_version bigint not null, + snapshot_hash text not null, + signature text not null, + status text not null, + created_by uuid references users(id), + created_at timestamptz not null, + unique (environment_id, snapshot_version) +); + +config_deployments ( + id uuid primary key, + snapshot_id uuid references config_snapshots(id), + gateway_id uuid references gateways(id), + cluster_id uuid references gateway_clusters(id), + status text not null, + deployed_at timestamptz, + acknowledged_at timestamptz, + error_message text, + created_at timestamptz not null +); + +audit_metadata_events ( + id uuid primary key, + organization_id uuid references organizations(id), + workspace_id uuid references workspaces(id), + gateway_id uuid references gateways(id), + execution_id text not null, + execution_started_at timestamptz not null, + route_key text, + verdict text not null, + reason_code text, + policy_hash text, + config_snapshot_hash text, + request_body_sha256 text, + response_status integer, + latency_total_ms integer, + metadata_json jsonb not null, + received_at timestamptz not null, + unique (organization_id, gateway_id, execution_id) +); +``` + +### Normalization + +Use third normal form for core identity, authorization, route, policy, gateway, and billing records: + +- organizations, users, roles, gateways, policies, and route versions are separate tables +- versioned configuration is immutable +- many-to-many relationships use join tables +- operational event tables are append-oriented and indexed around access patterns + +Use `jsonb` only where it is appropriate: + +- policy expressions +- route-specific match options +- event metadata +- gateway capability details + +Do not use `jsonb` for core relational fields such as `organization_id`, `workspace_id`, `gateway_id`, `status`, `created_at`, or version numbers. + +### Indexes + +Core indexes: + +```sql +create index on organization_memberships (user_id, organization_id); +create index on workspaces (organization_id); +create index on gateways (organization_id, status, last_seen_at desc); +create index on gateway_heartbeats (gateway_id, observed_at desc); +create index on route_definitions (workspace_id, route_key); +create index on route_versions (route_id, version_number desc); +create index on policy_versions (policy_set_id, version_number desc); +create index on config_snapshots (environment_id, snapshot_version desc); +create index on config_deployments (gateway_id, status, created_at desc); +create index on audit_metadata_events (organization_id, execution_started_at desc); +create index on audit_metadata_events (organization_id, route_key, execution_started_at desc); +create index on audit_metadata_events (organization_id, verdict, execution_started_at desc); +``` + +For early scale, keep audit metadata in Postgres. Add monthly partitioning once event volume justifies it. Move high-volume analytics to a columnar store later rather than making it a day-one dependency. + +### Gateway Local Data Model + +Customer-side gateways keep high-sensitivity records: + +- `execution_audit` +- `execution_artifacts` +- `replay_runs` +- `policy_snapshots` +- `audit_retention_checkpoints` +- local config snapshot cache +- gateway registration and key material references + +Hosted metadata references gateway-local records by `execution_id`, `gateway_id`, hashes, and timestamps. It does not store raw payloads by default. + +## API Design + +Use REST plus asynchronous event ingest for the first platform version. + +- REST is the default for UI, admin, gateway registration, config fetch, audit search, billing, and operational workflows. +- Gateway event ingest can start as REST batch endpoints and later move to Kafka-compatible private ingest, gRPC streaming, or regional collectors. +- GraphQL is optional for the UI later. +- gRPC is useful for gateway-control-plane streaming later, but REST long-poll plus signed snapshots is simpler and more debuggable early. + +### API Groups + +```text +/v1/auth/* +/v1/orgs/* +/v1/workspaces/* +/v1/environments/* +/v1/routes/* +/v1/policies/* +/v1/config-snapshots/* +/v1/gateway-registration/* +/v1/gateways/* +/v1/gateway-sync/* +/v1/audit-metadata/* +/v1/replay-exports/* +/v1/usage/* +/v1/billing/* +/v1/admin/* +``` + +### Authentication + +Human users: + +- OIDC/SAML SSO for enterprise organizations +- local login only if needed for early non-enterprise accounts +- short-lived access token plus refresh token +- organization and workspace scoped RBAC +- optional SCIM provisioning later + +Machine clients: + +- gateways register with a one-time enrollment token +- gateway receives a unique identity, key pair, and certificate or signed credential +- all gateway APIs require mTLS or signed request authentication +- credentials are rotatable and revocable +- every gateway request includes `gateway_id`, `organization_id`, timestamp, nonce or idempotency key, and signature + +### Versioning And Pagination + +Prefix all public APIs with `/v1`. + +Gateway APIs include: + +- `gateway_version` +- `capabilities` +- `min_supported_snapshot_version` +- `protocol_version` + +Use cursor pagination for list APIs: + +```http +GET /v1/orgs/{org_id}/audit-metadata?limit=100&cursor=... +``` + +Response: + +```json +{ + "items": [], + "next_cursor": "opaque_cursor", + "has_more": true +} +``` + +### Route And Policy APIs + +Representative endpoints: + +```http +POST /v1/orgs/{org_id}/workspaces +GET /v1/orgs/{org_id}/workspaces + +POST /v1/workspaces/{workspace_id}/routes +GET /v1/workspaces/{workspace_id}/routes +GET /v1/routes/{route_id} +POST /v1/routes/{route_id}/versions +POST /v1/route-versions/{version_id}/validate + +POST /v1/workspaces/{workspace_id}/policies +GET /v1/workspaces/{workspace_id}/policies +POST /v1/policies/{policy_set_id}/versions +POST /v1/policy-versions/{version_id}/validate +POST /v1/policy-versions/{version_id}/test + +POST /v1/environments/{environment_id}/config-snapshots +GET /v1/environments/{environment_id}/config-snapshots +POST /v1/config-snapshots/{snapshot_id}/promote +POST /v1/config-snapshots/{snapshot_id}/rollback +``` + +Policy test request: + +```json +{ + "route_version_id": "uuid", + "policy_version_ids": ["uuid"], + "sample_request": { + "method": "POST", + "headers": {}, + "body_json": {} + } +} +``` + +Policy test response: + +```json +{ + "verdict": "blocked", + "matched_policy": "pii-blocking", + "matched_rule": "deny-ssn", + "reason_code": "policy_rule_matched", + "evaluation_ms": 2 +} +``` + +### Config Snapshot APIs + +A config snapshot is immutable and signed. + +```http +POST /v1/environments/{environment_id}/config-snapshots +``` + +Request: + +```json +{ + "route_version_ids": ["uuid"], + "policy_version_ids": ["uuid"], + "description": "production rollout 2026-04-28" +} +``` + +Response: + +```json +{ + "snapshot_id": "uuid", + "snapshot_version": 42, + "snapshot_hash": "sha256:...", + "signature": "base64...", + "status": "created" +} +``` + +Deploy to cluster: + +```http +POST /v1/config-snapshots/{snapshot_id}/deployments +``` + +```json +{ + "target_type": "gateway_cluster", + "target_id": "uuid", + "strategy": "rolling", + "max_unavailable": 1 +} +``` + +### Gateway Registration APIs + +Enrollment starts from the control plane and finishes from the gateway. + +```http +POST /v1/orgs/{org_id}/gateway-enrollment-tokens +``` + +```json +{ + "cluster_id": "uuid", + "expires_in_minutes": 30, + "allowed_gateway_version": ">=1.0.0" +} +``` + +Gateway registration: + +```http +POST /v1/gateway-registration/register +``` + +```json +{ + "enrollment_token": "opaque", + "gateway_name": "prod-vpc-a-gw-1", + "public_key": "base64...", + "gateway_version": "1.0.3", + "capabilities": ["local_replay", "signed_snapshots", "metadata_ingest"] +} +``` + +Response: + +```json +{ + "gateway_id": "uuid", + "organization_id": "uuid", + "cluster_id": "uuid", + "credential_bundle": "encrypted-or-mtls-material", + "control_plane_base_url": "https://api.guardrail.example" +} +``` + +### Gateway Sync APIs + +Gateway heartbeat: + +```http +POST /v1/gateway-sync/heartbeat +``` + +```json +{ + "gateway_id": "uuid", + "gateway_version": "1.0.3", + "current_snapshot_hash": "sha256:...", + "current_snapshot_version": 41, + "status": "healthy", + "metrics_summary": { + "requests_1m": 1200, + "blocked_1m": 18, + "p95_added_latency_ms": 3 + } +} +``` + +Config check: + +```http +GET /v1/gateway-sync/config?gateway_id=uuid¤t_snapshot_version=41 +``` + +Response when an update exists: + +```json +{ + "update_available": true, + "snapshot_version": 42, + "snapshot_hash": "sha256:...", + "download_url": "https://api.guardrail.example/v1/gateway-sync/config-snapshots/uuid", + "signature": "base64..." +} +``` + +Gateway acknowledgement: + +```http +POST /v1/gateway-sync/config-ack +``` + +```json +{ + "gateway_id": "uuid", + "snapshot_id": "uuid", + "snapshot_version": 42, + "status": "applied", + "applied_at": "2026-04-28T10:00:00Z" +} +``` + +### Audit Metadata APIs + +Gateway batch ingest: + +```http +POST /v1/audit-metadata/batches +``` + +```json +{ + "gateway_id": "uuid", + "batch_id": "uuid", + "events": [ + { + "execution_id": "GR-EXE-...", + "execution_started_at": "2026-04-28T10:01:00Z", + "route_key": "partner-webhook", + "verdict": "blocked", + "reason_code": "policy_rule_matched", + "policy_hash": "sha256:...", + "config_snapshot_hash": "sha256:...", + "request_body_sha256": "sha256:...", + "response_status": 403, + "latency_total_ms": 4, + "metadata": {} + } + ] +} +``` + +Response: + +```json +{ + "batch_id": "uuid", + "accepted": 100, + "duplicates": 0, + "rejected": [] +} +``` + +Hosted audit search: + +```http +GET /v1/orgs/{org_id}/audit-metadata?workspace_id=...&route_key=...&verdict=blocked&from=...&to=... +``` + +Audit metadata detail: + +```http +GET /v1/audit-metadata/{event_id} +``` + +This returns metadata and local replay pointers, not raw payloads. + +### Error Contract + +All APIs return a consistent error shape: + +```json +{ + "error": { + "code": "config_snapshot_invalid", + "message": "The config snapshot contains a policy version from another workspace.", + "request_id": "req_...", + "details": {} + } +} +``` + +Apply rate limits by user ID, organization ID, gateway ID, endpoint class, write-heavy admin APIs, and ingest APIs. + +## High-Level Architecture + +Use a modular monolith control plane first, with clear internal domain boundaries and an async worker layer. Split into separate services only when scale, deployment cadence, or ownership requires it. + +Recommended first deployable units: + +```text +control-plane-api +control-plane-worker +gateway-ingest-worker or ingest-api +web-app +postgres +redis +event-bus / queue +object-storage +observability stack +customer-deployed gateways +``` + +### Architecture Diagram + +```text +Browser / CLI / Terraform Provider + | + v +CDN + WAF + | + v +API Load Balancer + | + v +control-plane-api + - auth and RBAC + - org/workspace management + - route and policy authoring + - config snapshot creation + - gateway registration + - audit metadata query + - billing and usage APIs + | + +--> Postgres + +--> Redis + +--> Object Storage + +--> Event Bus / Queue + | + v + control-plane-worker + - config compilation + - snapshot signing + - rollout orchestration + - ingest processing + - alert evaluation + - usage aggregation + - billing sync + - notifications + +Customer VPC + | + v +Guard Rail Gateway + - local policy enforcement + - local config verification + - upstream forwarding + - local audit ledger + - local replay artifacts + - heartbeat and metadata sync +``` + +### Control Plane Responsibilities + +The hosted control plane owns: + +- organizations, users, roles, and permissions +- route and policy source of truth +- immutable route and policy versions +- signed config snapshot compilation +- deployment intent and rollout state +- gateway identity and lifecycle +- gateway health and config drift detection +- audit metadata ingest and query +- usage metering and billing +- alerting and notification workflows + +The hosted control plane does not sit in the customer request path. + +### Gateway Responsibilities + +The customer-deployed gateway owns: + +- enforcing route and policy decisions locally +- validating signed config snapshots before use +- continuing to enforce the last valid snapshot during control-plane outages +- forwarding allowed requests to customer upstreams +- persisting local audit and replay records +- emitting metadata batches and health summaries +- supporting local retention and data operations + +Gateway failure behavior: + +- if no valid config has ever been loaded, gateway fails closed +- if a previously valid config exists and the control plane is unreachable, gateway continues with the last valid snapshot +- if a newer snapshot fails signature or schema validation, gateway rejects that snapshot and keeps the current one +- if metadata ingest fails, gateway buffers within configured limits and retries without blocking request enforcement + +### Caching Layers + +Use caching selectively: + +- CDN caches static web app assets and public documentation. +- Redis caches user session metadata, organization permission summaries, config compilation locks, idempotency keys, and short-lived gateway sync state. +- Gateways keep local in-memory config and policy evaluation caches for hot-path performance. +- Postgres remains the source of truth for control-plane state. + +Do not cache authorization decisions beyond short TTLs unless the cache is invalidated on membership and role changes. + +### Event Bus And Queues + +Use asynchronous processing for: + +- gateway heartbeat processing +- audit metadata batch ingestion +- usage aggregation +- config snapshot compilation +- rollout state transitions +- alert evaluation +- notification delivery +- billing provider synchronization + +Topics or queues: + +```text +gateway.heartbeat.received +gateway.audit_metadata.received +config_snapshot.requested +config_snapshot.compiled +config_deployment.requested +config_deployment.acknowledged +usage.event.recorded +alert.condition.detected +billing.sync.requested +``` + +Early implementation can use a managed queue with durable workers. Kafka or Redpanda becomes useful when ingest volume and replayable event streams justify it. + +### Config Rollout Flow + +```text +User creates policy/route version + -> user validates policy + -> user creates config snapshot for environment + -> API writes snapshot request + -> worker compiles canonical config + -> worker signs snapshot + -> user deploys snapshot to gateway cluster + -> gateways poll or long-poll for updates + -> gateway downloads snapshot + -> gateway verifies signature and schema + -> gateway stages config + -> gateway atomically switches active snapshot + -> gateway sends ack + -> control plane marks rollout progress +``` + +Rollout state is visible per cluster and gateway: + +```text +pending -> available -> downloaded -> applied +pending -> available -> failed_validation +pending -> available -> failed_apply +``` + +### Request Enforcement Flow + +```text +Client inside customer environment + -> Guard Rail Gateway + -> route lookup + -> method/auth checks + -> policy evaluation + -> block/reject or forward upstream + -> local audit write + -> local replay artifact write if configured + -> response to client + -> async metadata batch to hosted control plane +``` + +### Audit Metadata Flow + +```text +Gateway local execution record + -> local tamper-evident audit ledger + -> metadata projection + -> batch queue on gateway + -> hosted audit metadata ingest endpoint + -> ingest validation and dedupe + -> append audit_metadata_events + -> usage event emitted + -> alert rules evaluated async +``` + +Central audit search is a metadata index. Deep replay requires customer-local access or explicit export. + +### Monolith vs Microservices + +Start with: + +- one backend API codebase +- one worker codebase +- shared domain modules +- separate queues and worker jobs by domain +- strong module boundaries around identity, policy, config, gateways, audit metadata, billing, and notifications + +Extract later when there is real pressure: + +- `gateway-ingest` if audit metadata volume needs independent scaling +- `config-compiler` if snapshot compilation becomes CPU-heavy or security-sensitive +- `billing` if financial workflows require stricter isolation +- `notifications` if delivery volume grows +- `analytics` if reporting moves to a warehouse or columnar store + +## Low-Level Design + +### Control Plane Internal Modules + +Recommended backend modules: + +```text +identity + - users + - org memberships + - SSO connections + - service accounts + - RBAC checks + +workspace + - workspaces + - environments + - environment bindings + +policy + - policy sets + - policy versions + - validation + - test evaluation + +routes + - route definitions + - route versions + - upstream config encryption + - route-policy bindings + +config + - snapshot compiler + - canonical serialization + - signing + - rollout orchestration + - deployment state + +gateway + - enrollment tokens + - gateway identity + - key/cert rotation + - heartbeats + - drift detection + +audit_metadata + - ingest validation + - dedupe + - search + - alert projection + +usage + - usage events + - aggregation + - plan limits + +billing + - subscriptions + - invoice sync + - entitlement checks + +notifications + - email/slack/webhook delivery + - alert routing +``` + +Each module owns its tables through repository interfaces. Cross-module operations happen through application services and domain events, not direct table mutation from unrelated modules. + +### Core Domain Objects + +```text +Organization +Workspace +Environment +User +Role +Permission +GatewayCluster +Gateway +EnrollmentToken +RouteDefinition +RouteVersion +PolicySet +PolicyVersion +ConfigSnapshot +ConfigDeployment +AuditMetadataEvent +ReplayExport +UsageEvent +BillingAccount +Subscription +AlertRule +NotificationTarget +``` + +### State Machines + +Gateway lifecycle: + +```text +created -> enrollment_pending -> active -> degraded -> revoked +active -> rotating_credentials -> active +active -> disabled +degraded -> active +degraded -> revoked +``` + +Config snapshot lifecycle: + +```text +draft_requested -> compiling -> compiled -> signed -> deployable +compiling -> failed +signed -> archived +``` + +Deployment lifecycle: + +```text +created -> pending_gateway_pickup -> downloaded -> applied +pending_gateway_pickup -> timed_out +downloaded -> failed_validation +downloaded -> failed_apply +applied -> superseded +``` + +Policy version lifecycle: + +```text +draft -> validated -> approved -> included_in_snapshot +draft -> rejected +approved -> deprecated +``` + +### Config Compiler + +The config compiler produces deterministic output. + +Inputs: + +- environment +- route versions +- policy versions +- environment-specific bindings +- gateway capability constraints + +Steps: + +1. Load selected immutable route and policy versions in one transaction. +2. Verify every version belongs to the target workspace and environment. +3. Validate route-policy references. +4. Resolve environment-specific upstreams and secret references. +5. Produce canonical JSON or protobuf. +6. Compute snapshot hash over canonical bytes. +7. Sign snapshot hash and metadata with the control-plane signing key. +8. Store the immutable snapshot record and archive canonical bytes. +9. Emit `config_snapshot.compiled`. + +Use a strategy pattern for target formats if gateway config formats evolve: + +```text +ConfigCompiler + -> CanonicalJsonCompiler + -> FutureProtobufCompiler +``` + +### Gateway Sync + +Gateway sync is pull-based first. + +Reasons: + +- works through customer firewalls +- avoids inbound access into customer networks +- simplifies operations +- supports offline and degraded states naturally + +Gateway sync loop: + +```text +every N seconds with jitter: + send heartbeat + ask for latest deployment for gateway or cluster + if newer snapshot exists: + download snapshot + verify signature + validate schema and capability compatibility + stage snapshot on disk + run local consistency checks + atomically swap active config pointer + acknowledge success or failure +``` + +Use exponential backoff on failed control-plane calls. Keep request enforcement separate from sync loop failure. + +### Gateway Request Internals + +Gateway hot path: + +```text +HttpListener + -> RequestContextBuilder + -> ActiveConfigReader + -> RouteMatcher + -> AuthEvaluator + -> PolicyEvaluator + -> UpstreamForwarder + -> ExecutionRecorder + -> MetadataProjector +``` + +Design rules: + +- active config is immutable once loaded +- config swap is atomic +- policy evaluation does not call the hosted control plane +- audit writes are bounded and observable +- metadata upload is async and retryable +- upstream forwarding timeout is configurable per route + +### Key Sequences + +Policy rollout: + +```text +User -> API: create policy version +API -> PolicyValidator: validate +API -> Postgres: save immutable version +User -> API: create config snapshot +API -> Queue: config_snapshot.requested +Worker -> ConfigCompiler: compile +Worker -> KMS: sign +Worker -> Postgres: save signed snapshot +User -> API: deploy snapshot to cluster +Gateway -> API: heartbeat/config check +API -> Gateway: snapshot available +Gateway -> API/Object Storage: download +Gateway -> Gateway: verify/stage/apply +Gateway -> API: ack applied +API -> Postgres: update deployment state +``` + +Audit metadata ingest: + +```text +Gateway -> Local DB: write execution audit +Gateway -> Local DB/Object Store: write replay artifact if enabled +Gateway -> MetadataQueue: enqueue metadata projection +Gateway -> API: send batch with batch_id +API -> Auth: verify gateway credential +API -> IngestService: validate schema and org/gateway ownership +API -> Postgres: insert deduped events +API -> Queue: usage.event.recorded +API -> Gateway: accepted/duplicate/rejected counts +Worker -> AlertEvaluator: evaluate async +``` + +Control-plane outage: + +```text +Gateway sync loop -> Control Plane: request fails +Gateway -> Logs/Metrics: record sync failure +Gateway -> Local State: keep last valid snapshot +Gateway -> Request Path: continue enforcing +Gateway -> Local Buffer: retain metadata until buffer limit +Gateway -> Request Path: continue unless local storage is exhausted and policy requires fail-closed +``` + +### Design Patterns + +Use these patterns where they solve concrete problems: + +- repository pattern for database access +- unit of work for multi-table transactional writes like snapshot creation +- strategy pattern for config compiler formats and notification delivery providers +- outbox pattern for reliable event emission from Postgres-backed writes +- idempotency keys for gateway batch ingest and payment or billing callbacks +- circuit breaker for billing provider, notification provider, and optional external integrations +- immutable value objects for route versions, policy versions, and config snapshots + +Avoid singleton-based domain services. Use dependency injection through application wiring. + +### Error Handling + +Control plane: + +- validation errors return `400` +- auth failures return `401` +- permission failures return `403` +- hidden cross-tenant resources return `404` +- conflicts return `409` +- rate limits return `429` +- internal errors return `500` with request ID + +Gateway: + +- invalid config snapshot: reject snapshot, keep current config +- missing route: `404` +- invalid auth: `401` or `403` depending on route mode +- policy block: `403` +- rate limit: `429` +- upstream timeout or error: configured `502` or `504` +- local audit write failure: configurable, with fail-closed support for security-sensitive deployments + +### Testing Strategy + +- Unit tests for policy validation, config compiler, RBAC, and gateway route matching. +- Integration tests for Postgres repositories and migrations. +- Contract tests for gateway sync and metadata ingest APIs. +- End-to-end tests for policy rollout and gateway apply acknowledgement. +- Failure tests for control-plane outage, bad signatures, duplicate metadata batches, and stale snapshots. +- Load tests for gateway hot path, heartbeat ingest, and audit metadata batch ingest. + +## Scalability And Reliability + +The key scaling decision is that gateway request traffic does not traverse the hosted control plane. + +Scale independently: + +```text +Gateway hot path: + scales inside customer environments + +Hosted API: + scales by stateless horizontal replicas + +Workers: + scale by queue depth and job type + +Postgres: + scales vertically first, then read replicas, partitioning, and selected service extraction + +Redis: + scales for cache/rate-limit/session workloads + +Event bus: + scales ingest and async processing independently from API request latency +``` + +### Horizontal And Vertical Scaling + +Control plane API: + +- stateless containers behind load balancer +- horizontal autoscaling by CPU, memory, request rate, and latency +- no local durable state + +Workers: + +- horizontal autoscaling by queue depth and oldest message age +- separate worker pools for config compilation, ingest, billing, notifications, and alerting when needed + +Postgres: + +- start with managed primary plus read replica +- vertical scale first for early enterprise stage +- add partitioning for audit metadata and usage events +- add read replicas for audit search and reporting +- later split high-write event tables or move analytics to columnar storage + +Gateways: + +- horizontally deployed by customers per region or VPC +- active-active gateway replicas behind customer internal load balancer +- each gateway keeps local config and local audit storage +- cluster-level rollout state tracks which replicas have applied snapshots + +### Database Partitioning And Sharding + +Do not shard the core control-plane database on day one. + +Use: + +- tenant-aware indexes on `organization_id` +- monthly partitioning for `audit_metadata_events` +- monthly partitioning for `usage_events` +- archival and lifecycle policies for old event partitions +- read replicas for reporting and search load + +Future sharding path: + +- shard by `organization_id` for very large customers +- move audit metadata to a dedicated event database or columnar store +- keep identity, billing, and config source of truth in a strongly consistent relational store +- introduce regional control-plane cells for data residency + +### Replication + +Hosted control plane: + +- managed Postgres primary in one region +- synchronous or semi-synchronous storage replication depending on cloud provider +- at least one read replica +- PITR enabled +- regular logical backups tested through restore drills +- Redis configured for managed HA +- queue/event bus durable replication enabled + +Gateway local: + +- customer decides local database HA posture +- recommended production gateway cluster uses external Postgres or managed equivalent +- local audit durability should match customer compliance needs +- metadata buffering should survive gateway restarts + +### Failover And Disaster Recovery + +Control plane RPO/RTO baseline: + +- RPO: 5-15 minutes for hosted control-plane data +- RTO: 1-4 hours for full regional restore in early enterprise stage +- stricter targets can be added for higher tiers later + +Failure modes: + +- API replica failure: load balancer routes around it +- worker failure: message visibility timeout and retry +- Redis failure: degraded caching and rate limits; no permanent data loss +- queue failure: backpressure non-critical async paths +- Postgres primary failure: managed failover +- full control-plane outage: gateways continue with last valid config +- object storage outage: config snapshot downloads and exports degrade; active gateways continue + +### Gateway Offline Behavior + +Rules: + +- enforce last valid signed config +- reject unsigned or expired snapshots +- continue local audit writes +- buffer metadata upload up to configured storage limit +- expose local health indicating sync degradation +- never allow traffic because the control plane is unreachable +- fail closed if no valid config exists + +Snapshot expiration is configurable. Highly regulated customers may require gateways to fail closed after a maximum offline period. + +### CAP Model + +Gateway enforcement: + +- choose availability and partition tolerance for the request path using the last valid signed config +- accept temporary staleness during a control-plane partition +- preserve safety through signatures, config versioning, and expiration policy + +Control-plane writes: + +- choose consistency and partition tolerance +- route, policy, and config writes require the primary database +- avoid accepting conflicting policy writes in multiple regions in the first version + +Audit metadata: + +- choose availability and eventual consistency +- gateway local audit ledger is authoritative for full execution history +- hosted metadata can arrive late and is deduped by `(organization_id, gateway_id, execution_id)` + +### Reliability Controls + +Use: + +- idempotency keys for retried writes +- outbox pattern for reliable domain event publication +- dead-letter queues for poison messages +- retry with exponential backoff and jitter +- circuit breakers for external providers +- health checks for API, workers, database, Redis, and queues +- readiness checks that fail when required dependencies are unavailable +- graceful shutdown for API and workers +- migration gates before deployment +- canary or rolling deployments for API and workers +- staged config rollout for gateways + +### Backpressure + +Control plane should shed load predictably: + +- rate limit noisy users, organizations, and gateways +- accept smaller metadata batches when large batches overload ingest +- return `429` with `Retry-After` +- pause non-critical workers before critical config and auth flows +- prioritize gateway config sync over heavy audit search during incidents + +Gateway should also apply backpressure: + +- bounded metadata buffer +- bounded local replay artifact storage +- retention cleanup +- configurable behavior when local audit storage is unavailable +- request body size limits +- upstream timeout limits + +### Consistency Rules + +- Route and policy versions are immutable. +- Config snapshots are immutable. +- Deployment state is mutable and operational. +- Gateway active config changes only through atomic snapshot swap. +- Audit metadata ingest is append-only and deduped. +- Billing usage aggregation can be eventually consistent. +- RBAC changes should take effect quickly, with short cache TTL and invalidation. + +### Upgrade Path To Larger Scale + +When the platform grows beyond early enterprise: + +1. Extract gateway ingest into a separate service. +2. Partition audit metadata and usage event storage aggressively. +3. Add regional ingest endpoints near customers. +4. Move analytics to ClickHouse, BigQuery, Snowflake, or similar. +5. Add regional control-plane cells for data residency. +6. Split high-scale customers onto dedicated database shards. +7. Introduce gRPC streaming for gateway fleets that need lower-latency rollout. +8. Add multi-region read replicas and eventually active-active only for carefully scoped read/query surfaces. + +## Observability + +Control plane telemetry: + +- structured JSON logs with `request_id`, `organization_id`, `user_id`, `gateway_id`, and `trace_id` +- Prometheus/OpenTelemetry metrics +- distributed traces across API, workers, queue jobs, database calls, and external providers +- audit actor log for user and admin actions +- immutable security event log for sensitive actions + +Core metrics: + +```text +api_request_count +api_request_latency_ms +api_error_count +auth_failure_count +rbac_denial_count +gateway_heartbeat_count +gateway_last_seen_age_seconds +gateway_config_drift_count +config_snapshot_compile_duration_ms +config_deployment_apply_latency_seconds +audit_metadata_batch_count +audit_metadata_ingest_lag_seconds +audit_metadata_rejected_count +queue_depth +queue_oldest_message_age_seconds +postgres_connection_pool_usage +worker_job_failures +billing_sync_failures +notification_failures +``` + +Gateway telemetry: + +```text +gateway_request_count +gateway_policy_verdict_count +gateway_added_latency_ms +gateway_upstream_latency_ms +gateway_audit_write_failures +gateway_metadata_buffer_depth +gateway_metadata_flush_failures +gateway_active_snapshot_version +gateway_config_apply_failures +gateway_control_plane_sync_failures +``` + +Alerts: + +- gateway offline or stale heartbeat +- config drift after rollout deadline +- snapshot compile failure +- metadata ingest lag above threshold +- elevated policy block rate +- audit write failures on gateway +- queue backlog +- database saturation +- error budget burn +- billing sync failures +- suspicious auth failures or enrollment attempts + +## Security + +### Identity And Access + +- OIDC/SAML SSO for enterprise organizations. +- MFA for local accounts and privileged users. +- SCIM later for enterprise provisioning. +- Organization, workspace, and environment RBAC. +- Separate roles for owner, admin, policy editor, deploy approver, auditor, billing admin, and read-only viewer. +- Service accounts with scoped tokens. +- Break-glass admin access with strict logging and time bounds. + +### Gateway Security + +- One-time enrollment token with short TTL. +- Gateway-generated key pair. +- mTLS or signed requests for gateway APIs. +- Credential rotation. +- Gateway revocation. +- Signed config snapshots. +- Gateway verifies snapshot signature and hash before applying. +- No hosted control-plane dependency during request enforcement. +- Least-privilege egress from gateway to control-plane endpoints. + +### Data Protection + +- TLS everywhere. +- Encryption at rest for Postgres, Redis, queue storage, and object storage. +- KMS-managed keys. +- Envelope encryption for upstream URLs, secrets, and customer-sensitive config. +- Raw request and response payloads stay customer-local by default. +- Optional exports require explicit customer configuration and retention policy. +- Secrets never appear in logs, metadata events, or config snapshots in plaintext. + +### Tenant Isolation + +- Every hosted table includes tenant ownership through `organization_id` or a parent relation. +- API authorization checks verify organization and workspace membership. +- Cross-tenant resource access returns `404` where resource existence should be hidden. +- Background jobs carry tenant context. +- Object storage paths include organization-scoped prefixes and IAM restrictions. +- Audit metadata dedupes within organization and gateway scope. + +### Policy Supply-Chain Integrity + +- Immutable policy versions. +- Immutable route versions. +- Signed config snapshots. +- Snapshot hash included in gateway audit metadata. +- Optional two-person approval for production deployments. +- Deployment history preserved. +- Rollback creates a new deployment event pointing to a previous immutable snapshot. + +### Rate Limiting And Abuse Controls + +- Per-user and per-organization admin API limits. +- Per-gateway heartbeat and ingest limits. +- WAF for public API. +- Bot and brute-force protections on auth endpoints. +- Payload size limits. +- Metadata batch size limits. +- Idempotency to prevent retry amplification. + +## Infrastructure + +Baseline cloud architecture: + +```text +DNS + -> CDN/WAF + -> API Load Balancer + -> control-plane-api containers + -> Postgres / Redis / Queue / Object Storage + +Worker Autoscaling Group + -> queue consumers + -> Postgres / Redis / Object Storage / external providers + +Web App + -> CDN-hosted static assets + -> API + +Observability + -> logs + -> metrics + -> traces + -> alert manager +``` + +Recommended managed dependencies: + +- managed Postgres with PITR and backups +- managed Redis +- managed queue or event bus +- object storage with lifecycle policies +- KMS and secrets manager +- container registry +- managed log, metric, and trace backend +- external billing provider +- transactional email provider +- optional Slack and webhook notification integrations + +Environments: + +```text +dev +staging +production +``` + +Deployment rules: + +- migrations run as explicit jobs before API and worker rollout +- app startup does not auto-run destructive migrations +- blue/green or rolling deploy for API +- workers drain current jobs before shutdown +- config compiler and signing code changes receive extra review +- infrastructure is managed with Terraform or OpenTofu +- secrets are injected through a secrets manager, not committed env files +- CI runs unit, integration, contract, migration, and smoke tests + +### Infrastructure Security Controls + +- Private networking for databases and queues. +- No public database access. +- Least-privilege IAM per service. +- Separate KMS keys for signing, database encryption, and object exports. +- Access logs for admin and production infrastructure actions. +- Vulnerability scanning for containers. +- Dependency scanning. +- SAST and secret scanning. +- Regular restore drills. +- Incident runbooks. + +## Data Retention + +Hosted defaults: + +- audit metadata: 180-365 days by plan +- gateway health summaries: 90 days detailed, longer aggregated +- usage events: retained according to billing and legal requirements +- security events: 1-7 years depending on compliance tier +- object exports: customer-configurable lifecycle +- deleted organizations: soft-delete first, hard-delete after retention window unless legal hold exists + +Gateway defaults: + +- local audit retention configurable by customer +- local replay artifact retention shorter than audit metadata +- tamper-evident checkpoints preserve audit-chain verification across cleanup +- cleanup is explicit and observable + +## Acceptance Criteria + +The architecture is successful when: + +- customer request traffic can be enforced without traversing the hosted control plane +- gateways continue with the last valid config during control-plane outages +- hosted control plane can manage organizations, users, routes, policies, snapshots, gateways, metadata, and billing +- full payloads remain customer-local by default +- policy and config rollout is signed, auditable, and reversible +- audit metadata is searchable centrally without centralizing sensitive bodies +- the system can scale through early enterprise usage without sharding core data +- there is a credible path to regional ingest, sharded metadata, and multi-region control plane later + +## Implementation Notes + +This document is a platform system design, not an implementation plan. The current repository should continue to describe the existing runtime as a verified pilot deployment until platform implementation work is planned and built. + +The first implementation plan should decompose this architecture into independently deliverable programs: + +1. hosted identity, organization, workspace, and RBAC foundation +2. route and policy versioning APIs +3. signed config snapshot compiler +4. gateway enrollment and sync protocol +5. hosted audit metadata ingest and search +6. usage, billing, and alerting +7. infrastructure, observability, and security hardening diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..1dd382b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -9,9 +9,19 @@ const eslintConfig = defineConfig([ globalIgnores([ // Default ignores of eslint-config-next: ".next/**", + "**/.next/**", "out/**", + "**/out/**", "build/**", + "**/build/**", + "coverage/**", + "**/coverage/**", + "node_modules/**", + "**/node_modules/**", + ".worktrees/**", + "worktrees/**", "next-env.d.ts", + "**/*.tsbuildinfo", ]), ]); diff --git a/guard-rail-engine/Cargo.lock b/guard-rail-engine/Cargo.lock index 2dedf02..de3a43b 100644 --- a/guard-rail-engine/Cargo.lock +++ b/guard-rail-engine/Cargo.lock @@ -1602,16 +1602,9 @@ dependencies = [ "lazy_static", "memchr", "parking_lot", - "protobuf", "thiserror 1.0.69", ] -[[package]] -name = "protobuf" -version = "2.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94" - [[package]] name = "quote" version = "1.0.45" @@ -1816,9 +1809,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", diff --git a/guard-rail-engine/Cargo.toml b/guard-rail-engine/Cargo.toml index b4adf8f..663764b 100644 --- a/guard-rail-engine/Cargo.toml +++ b/guard-rail-engine/Cargo.toml @@ -30,10 +30,10 @@ chrono = { version = "0.4", features = ["serde"] } url = "2" glob-match = "0.2" bytes = "1" -sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "migrate"] } +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "migrate", "macros"] } sha2 = "0.10" hex = "0.4" -prometheus = "0.13" +prometheus = { version = "0.13", default-features = false } [dev-dependencies] tower = { version = "0.5", features = ["util"] } diff --git a/guard-rail-engine/config/config.yaml b/guard-rail-engine/config/config.yaml index 1027e4d..cc0eeaf 100644 --- a/guard-rail-engine/config/config.yaml +++ b/guard-rail-engine/config/config.yaml @@ -23,6 +23,7 @@ database: audit: write_timeout_ms: 250 + persistence_mode: best_effort admin: token: "change-me" @@ -50,4 +51,4 @@ observability: shutdown: grace_period_ms: 15000 - drain_poll_interval_ms: 50 \ No newline at end of file + drain_poll_interval_ms: 50 diff --git a/guard-rail-engine/deploy/container/config.yaml b/guard-rail-engine/deploy/container/config.yaml index 082cff9..4676afa 100644 --- a/guard-rail-engine/deploy/container/config.yaml +++ b/guard-rail-engine/deploy/container/config.yaml @@ -21,6 +21,7 @@ database: audit: write_timeout_ms: 250 + persistence_mode: required_before_response admin: token: "" diff --git a/guard-rail-engine/deploy/verification/README.md b/guard-rail-engine/deploy/verification/README.md index 7721c3a..2e122ae 100644 --- a/guard-rail-engine/deploy/verification/README.md +++ b/guard-rail-engine/deploy/verification/README.md @@ -116,9 +116,13 @@ bash scripts/verification/dependency-audits.sh Expected: - writes `dependency-audits.json` -- exits `0` if prerequisites are present and audits pass -- exits `0` with status `skipped` if prerequisites are missing +- exits `0` when Rust and Node dependency audits pass +- exits `0` with status `skipped` in default mode when audit prerequisites are missing or skipped +- exits non-zero when `cargo audit` reports Rust dependency vulnerabilities +- exits non-zero when `npm audit --audit-level=high` reports high-or-above Node dependency vulnerabilities +- exits non-zero when `REQUIRE_DEPENDENCY_AUDIT=true` and audit prerequisites are missing or skipped - `cargo audit` covers Rust dependencies +- `RUSTSEC-2023-0071` is explicitly ignored because it is pulled through SQLx's optional MySQL package in `Cargo.lock`; Guard Rail enables only Postgres SQLx features and `sqlx-mysql` is not in the active dependency tree - `npm audit --audit-level=high` covers Node dependencies ## Running The Full Suite diff --git a/guard-rail-engine/scripts/verification/dependency-audits.sh b/guard-rail-engine/scripts/verification/dependency-audits.sh index 636b666..8d36353 100755 --- a/guard-rail-engine/scripts/verification/dependency-audits.sh +++ b/guard-rail-engine/scripts/verification/dependency-audits.sh @@ -4,14 +4,18 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=./common.sh source "${SCRIPT_DIR}/common.sh" +ENGINE_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" +REPO_ROOT="$(cd "${ENGINE_DIR}/.." && pwd)" +REQUIRE_DEPENDENCY_AUDIT="${REQUIRE_DEPENDENCY_AUDIT:-false}" +export PHASE7_SUITE_ID="${PHASE7_SUITE_ID:-$(phase7_timestamp)}" rust_status="skipped" node_status="skipped" # Rust audit if command -v cargo-audit >/dev/null 2>&1; then - pushd /home/nyasha-hama/projects/guard-rail/.worktrees/phase7-verification/guard-rail-engine >/dev/null - if cargo audit; then + pushd "${ENGINE_DIR}" >/dev/null + if cargo audit --ignore RUSTSEC-2023-0071; then rust_status="pass" else rust_status="fail" @@ -23,16 +27,21 @@ else fi # Node audit -if [[ -d /home/nyasha-hama/projects/guard-rail/.worktrees/phase7-verification/node_modules ]]; then - pushd /home/nyasha-hama/projects/guard-rail/.worktrees/phase7-verification >/dev/null - if npm audit --audit-level=high; then - node_status="pass" +if [[ -f "${REPO_ROOT}/package-lock.json" ]]; then + if command -v npm >/dev/null 2>&1; then + pushd "${REPO_ROOT}" >/dev/null + if npm audit --audit-level=high; then + node_status="pass" + else + node_status="fail" + fi + popd >/dev/null else - node_status="fail" + echo "npm is required for Node dependency audit but not installed" >&2 + node_status="missing" fi - popd >/dev/null else - echo "node_modules not found — skipping npm audit" >&2 + echo "package-lock.json not found; skipping npm audit" >&2 node_status="skipped" fi @@ -44,15 +53,20 @@ print(json.dumps({ })) ") -if [[ "${rust_status}" == "pass" && "${node_status}" == "pass" ]]; then +if [[ "${rust_status}" == "fail" || "${node_status}" == "fail" ]]; then + phase7_write_result_json "dependency-audits" "fail" "${metrics}" + echo "dependency-audits: FAIL" + exit 1 +elif [[ "${rust_status}" == "pass" && "${node_status}" == "pass" ]]; then phase7_write_result_json "dependency-audits" "pass" "${metrics}" echo "dependency-audits: PASS" -elif [[ "${rust_status}" == "skipped" || "${rust_status}" == "missing" || "${node_status}" == "skipped" ]]; then - phase7_write_result_json "dependency-audits" "skipped" "${metrics}" - echo "dependency-audits: SKIPPED (missing prerequisites)" exit 0 -else +elif [[ "${REQUIRE_DEPENDENCY_AUDIT}" == "true" ]]; then phase7_write_result_json "dependency-audits" "fail" "${metrics}" - echo "dependency-audits: FAIL" + echo "dependency-audits: FAIL (missing required audit prerequisites)" exit 1 +else + phase7_write_result_json "dependency-audits" "skipped" "${metrics}" + echo "dependency-audits: SKIPPED (missing prerequisites)" + exit 0 fi diff --git a/guard-rail-engine/src/config.rs b/guard-rail-engine/src/config.rs index 010234b..538442a 100644 --- a/guard-rail-engine/src/config.rs +++ b/guard-rail-engine/src/config.rs @@ -109,10 +109,20 @@ fn default_max_connections() -> u32 { 10 } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum AuditPersistenceMode { + #[default] + BestEffort, + RequiredBeforeResponse, +} + #[derive(Debug, Clone, Deserialize)] pub struct AuditConfig { #[serde(default = "default_write_timeout_ms")] pub write_timeout_ms: u64, + #[serde(default)] + pub persistence_mode: AuditPersistenceMode, } fn default_write_timeout_ms() -> u64 { @@ -340,6 +350,21 @@ impl AppConfig { if let Ok(val) = std::env::var("GUARDRAIL_AUDIT__WRITE_TIMEOUT_MS") { config.audit.write_timeout_ms = val.parse()?; } + if let Ok(val) = std::env::var("GUARDRAIL_AUDIT__PERSISTENCE_MODE") { + config.audit.persistence_mode = match val.trim().to_ascii_lowercase().as_str() { + "best_effort" => AuditPersistenceMode::BestEffort, + "required_before_response" => AuditPersistenceMode::RequiredBeforeResponse, + other => { + return Err( + format!( + "invalid GUARDRAIL_AUDIT__PERSISTENCE_MODE value '{}'; expected best_effort or required_before_response", + other + ) + .into(), + ); + } + }; + } if let Ok(val) = std::env::var("GUARDRAIL_ADMIN__TOKEN") { config.admin.token = val; } @@ -417,6 +442,7 @@ mod tests { std::env::remove_var("GUARDRAIL_DATABASE__URL"); std::env::remove_var("GUARDRAIL_DATABASE__MAX_CONNECTIONS"); std::env::remove_var("GUARDRAIL_AUDIT__WRITE_TIMEOUT_MS"); + std::env::remove_var("GUARDRAIL_AUDIT__PERSISTENCE_MODE"); std::env::remove_var("GUARDRAIL_ADMIN__TOKEN"); std::env::remove_var("GUARDRAIL_TENANT_AUTH__HEADER_NAME"); std::env::remove_var("GUARDRAIL_RATE_LIMIT__REQUESTS_PER_MINUTE"); @@ -524,6 +550,69 @@ rate_limit: {} assert_eq!(config.audit.write_timeout_ms, 250); } + #[test] + fn test_audit_persistence_mode_defaults_to_best_effort() { + let _env = EnvTestGuard::new(); + let yaml = r#" +server: + host: "0.0.0.0" + port: 8080 +routes_file: "./routes.yaml" +policies_dir: "./policies/" +forwarding: {} +logging: {} +database: + url: "postgres://test:test@localhost:5432/test" +audit: {} +admin: + token: "default-admin" +rate_limit: {} +"#; + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + tmp.write_all(yaml.as_bytes()).unwrap(); + + let config = AppConfig::load(tmp.path()).unwrap(); + assert_eq!( + config.audit.persistence_mode, + AuditPersistenceMode::BestEffort + ); + } + + #[test] + fn test_audit_persistence_mode_env_override() { + let _env = EnvTestGuard::new(); + let yaml = r#" +server: + host: "0.0.0.0" + port: 8080 +routes_file: "./routes.yaml" +policies_dir: "./policies/" +forwarding: {} +logging: {} +database: + url: "postgres://test:test@localhost:5432/test" +audit: {} +admin: + token: "default-admin" +rate_limit: {} +"#; + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + tmp.write_all(yaml.as_bytes()).unwrap(); + + unsafe { + std::env::set_var( + "GUARDRAIL_AUDIT__PERSISTENCE_MODE", + "required_before_response", + ); + } + + let config = AppConfig::load(tmp.path()).unwrap(); + assert_eq!( + config.audit.persistence_mode, + AuditPersistenceMode::RequiredBeforeResponse + ); + } + #[test] fn test_load_invalid_yaml_errors() { let _env = EnvTestGuard::new(); diff --git a/guard-rail-engine/src/main.rs b/guard-rail-engine/src/main.rs index 433f27d..4bba22b 100644 --- a/guard-rail-engine/src/main.rs +++ b/guard-rail-engine/src/main.rs @@ -207,6 +207,7 @@ async fn main() -> Result<(), Box> { policies, http_client, audit_store: Some(audit_store), + audit_persistence_mode: app_config.audit.persistence_mode, metrics: metrics.clone(), lifecycle: lifecycle.clone(), readiness_probe_timeout_ms: app_config.observability.readiness_probe_timeout_ms, diff --git a/guard-rail-engine/src/proxy/mod.rs b/guard-rail-engine/src/proxy/mod.rs index dbf3d30..9987e05 100644 --- a/guard-rail-engine/src/proxy/mod.rs +++ b/guard-rail-engine/src/proxy/mod.rs @@ -4,7 +4,7 @@ pub mod response; use crate::audit::hash::{hash_body, hash_string}; use crate::auth::context::{RequestAuthContext, authenticate_tenant_request}; use crate::auth::rate_limit::TenantRateLimiter; -use crate::config::ReplayConfig; +use crate::config::{AuditPersistenceMode, ReplayConfig}; use crate::execution::{ExecutionRecord, ExecutionVerdict}; use crate::logging::ExecutionLog; use crate::observability::metrics::Metrics; @@ -46,6 +46,7 @@ pub struct AppState { pub policies: Arc>, pub http_client: Client, pub audit_store: Option, + pub audit_persistence_mode: crate::config::AuditPersistenceMode, pub metrics: Option>, pub lifecycle: LifecycleState, pub readiness_probe_timeout_ms: u64, @@ -70,6 +71,7 @@ impl std::fmt::Debug for AppState { .field("policies", &">") .field("http_client", &"") .field("audit_store", &self.audit_store.is_some()) + .field("audit_persistence_mode", &self.audit_persistence_mode) .field("metrics", &self.metrics.is_some()) .field("route_config_hash", &self.route_config_hash) .field("policy_set_hash", &self.policy_set_hash) @@ -77,52 +79,78 @@ impl std::fmt::Debug for AppState { } } -#[allow(dead_code)] -async fn emit_and_persist( - record: ExecutionRecord, - audit_store: Option, - _metrics: Option>, -) { - ExecutionLog::from(&record).emit(); - if let Some(store) = audit_store { - let _ = tokio::time::timeout( - std::time::Duration::from_millis(250), - store.insert_execution(&record), - ) - .await; - } -} - -fn spawn_emit_and_persist( +async fn persist_execution_record( record: ExecutionRecord, audit_store: Option, metrics: Option>, -) { + mode: AuditPersistenceMode, +) -> Result<(), ()> { let log = ExecutionLog::from(&record); log.emit(); - if let Some(store) = audit_store { - tokio::spawn(async move { + + match (mode, audit_store) { + (AuditPersistenceMode::BestEffort, Some(store)) => { + tokio::spawn(async move { + if let Err(e) = store.insert_execution(&record).await { + tracing::error!(error = %e, execution_id = %record.execution_id, "failed to persist execution"); + if let Some(metrics) = metrics { + metrics.record_audit_persist_failure("insert_execution"); + } + } + }); + Ok(()) + } + (AuditPersistenceMode::BestEffort, None) => Ok(()), + (AuditPersistenceMode::RequiredBeforeResponse, Some(store)) => { if let Err(e) = store.insert_execution(&record).await { tracing::error!(error = %e, execution_id = %record.execution_id, "failed to persist execution"); if let Some(metrics) = metrics { metrics.record_audit_persist_failure("insert_execution"); } + Err(()) + } else { + Ok(()) + } + } + (AuditPersistenceMode::RequiredBeforeResponse, None) => { + tracing::error!(execution_id = %record.execution_id, "audit persistence required but audit store is unavailable"); + if let Some(metrics) = metrics { + metrics.record_audit_persist_failure("missing_audit_store"); } - }); + Err(()) + } } } -fn spawn_emit_and_persist_bundle( +async fn persist_execution_bundle( record: ExecutionRecord, artifacts: Option, snapshot: Option, audit_store: Option, metrics: Option>, -) { + mode: AuditPersistenceMode, +) -> Result<(), ()> { let log = ExecutionLog::from(&record); log.emit(); - if let Some(store) = audit_store { - tokio::spawn(async move { + + match (mode, audit_store) { + (AuditPersistenceMode::BestEffort, Some(store)) => { + tokio::spawn(async move { + if let Err(e) = store + .insert_execution_bundle(&record, artifacts.as_ref(), snapshot.as_ref()) + .await + { + tracing::error!(error = %e, execution_id = %record.execution_id, "failed to persist execution bundle"); + if let Some(metrics) = metrics { + metrics.record_audit_persist_failure("insert_execution_bundle"); + metrics.record_replay_persist_failure("insert_execution_bundle"); + } + } + }); + Ok(()) + } + (AuditPersistenceMode::BestEffort, None) => Ok(()), + (AuditPersistenceMode::RequiredBeforeResponse, Some(store)) => { if let Err(e) = store .insert_execution_bundle(&record, artifacts.as_ref(), snapshot.as_ref()) .await @@ -132,8 +160,19 @@ fn spawn_emit_and_persist_bundle( metrics.record_audit_persist_failure("insert_execution_bundle"); metrics.record_replay_persist_failure("insert_execution_bundle"); } + Err(()) + } else { + Ok(()) } - }); + } + (AuditPersistenceMode::RequiredBeforeResponse, None) => { + tracing::error!(execution_id = %record.execution_id, "audit persistence required but audit store is unavailable"); + if let Some(metrics) = metrics { + metrics.record_audit_persist_failure("missing_audit_store"); + metrics.record_replay_persist_failure("missing_audit_store"); + } + Err(()) + } } } @@ -256,11 +295,17 @@ pub async fn handle_execute( "REJECTED", total_start.elapsed().as_secs_f64(), ); - spawn_emit_and_persist( + if persist_execution_record( record, state.audit_store.clone(), state.metrics.clone(), - ); + state.audit_persistence_mode, + ) + .await + .is_err() + { + return response::audit_persistence_error_response(&execution_id); + } return response::reject_response( &execution_id, "Route is not bound to a tenant", @@ -322,11 +367,17 @@ pub async fn handle_execute( total_start.elapsed().as_secs_f64(), ); tracing::Span::current().record("verdict", "REJECTED"); - spawn_emit_and_persist( + if persist_execution_record( record, state.audit_store.clone(), state.metrics.clone(), - ); + state.audit_persistence_mode, + ) + .await + .is_err() + { + return response::audit_persistence_error_response(&execution_id); + } return ( axum::http::StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded", @@ -382,11 +433,17 @@ pub async fn handle_execute( "REJECTED", total_start.elapsed().as_secs_f64(), ); - spawn_emit_and_persist( + if persist_execution_record( record, state.audit_store.clone(), state.metrics.clone(), - ); + state.audit_persistence_mode, + ) + .await + .is_err() + { + return response::audit_persistence_error_response(&execution_id); + } return (axum::http::StatusCode::NOT_FOUND, "Route not found").into_response(); } Ok(RequestAuthContext::Admin) => (None, None, Some("admin".to_string())), @@ -434,11 +491,17 @@ pub async fn handle_execute( total_start.elapsed().as_secs_f64(), ); tracing::Span::current().record("verdict", "REJECTED"); - spawn_emit_and_persist( + if persist_execution_record( record, state.audit_store.clone(), state.metrics.clone(), - ); + state.audit_persistence_mode, + ) + .await + .is_err() + { + return response::audit_persistence_error_response(&execution_id); + } return (axum::http::StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); } } @@ -487,7 +550,17 @@ pub async fn handle_execute( total_start.elapsed().as_secs_f64(), ); tracing::Span::current().record("verdict", "REJECTED"); - spawn_emit_and_persist(record, state.audit_store.clone(), state.metrics.clone()); + if persist_execution_record( + record, + state.audit_store.clone(), + state.metrics.clone(), + state.audit_persistence_mode, + ) + .await + .is_err() + { + return response::audit_persistence_error_response(&execution_id); + } return response::method_not_allowed_response(&execution_id); } @@ -534,7 +607,17 @@ pub async fn handle_execute( total_start.elapsed().as_secs_f64(), ); tracing::Span::current().record("verdict", "REJECTED"); - spawn_emit_and_persist(record, state.audit_store.clone(), state.metrics.clone()); + if persist_execution_record( + record, + state.audit_store.clone(), + state.metrics.clone(), + state.audit_persistence_mode, + ) + .await + .is_err() + { + return response::audit_persistence_error_response(&execution_id); + } return response::reject_response(&execution_id, "Invalid JSON body"); } }; @@ -624,13 +707,19 @@ pub async fn handle_execute( total_start.elapsed().as_secs_f64(), ); tracing::Span::current().record("verdict", "BLOCKED"); - spawn_emit_and_persist_bundle( + if persist_execution_bundle( record, Some(artifacts), Some(snapshot), state.audit_store.clone(), state.metrics.clone(), - ); + state.audit_persistence_mode, + ) + .await + .is_err() + { + return response::audit_persistence_error_response(&execution_id); + } } else { if let Some(metrics) = &state.metrics { metrics.record_policy_latency( @@ -648,7 +737,17 @@ pub async fn handle_execute( total_start.elapsed().as_secs_f64(), ); tracing::Span::current().record("verdict", "BLOCKED"); - spawn_emit_and_persist(record, state.audit_store.clone(), state.metrics.clone()); + if persist_execution_record( + record, + state.audit_store.clone(), + state.metrics.clone(), + state.audit_persistence_mode, + ) + .await + .is_err() + { + return response::audit_persistence_error_response(&execution_id); + } } return response::block_response(&execution_id, &policy_name, &rule_field, &message); } @@ -768,15 +867,31 @@ pub async fn handle_execute( response_body_sha256, response_body_truncated: truncated, }; - spawn_emit_and_persist_bundle( + if persist_execution_bundle( record, Some(artifacts), snapshot, state.audit_store.clone(), state.metrics.clone(), - ); + state.audit_persistence_mode, + ) + .await + .is_err() + { + return response::audit_persistence_error_response(&execution_id); + } } else { - spawn_emit_and_persist(record, state.audit_store.clone(), state.metrics.clone()); + if persist_execution_record( + record, + state.audit_store.clone(), + state.metrics.clone(), + state.audit_persistence_mode, + ) + .await + .is_err() + { + return response::audit_persistence_error_response(&execution_id); + } } result.response } @@ -829,7 +944,17 @@ pub async fn handle_execute( forward_elapsed.as_secs_f64(), ); } - spawn_emit_and_persist(record, state.audit_store.clone(), state.metrics.clone()); + if persist_execution_record( + record, + state.audit_store.clone(), + state.metrics.clone(), + state.audit_persistence_mode, + ) + .await + .is_err() + { + return response::audit_persistence_error_response(&execution_id); + } response::bad_gateway_response(&execution_id, &e) } } @@ -1046,6 +1171,7 @@ impl AppState { policies: Arc::new(RwLock::new(PolicySet::new())), http_client: Client::new(), audit_store: None, + audit_persistence_mode: AuditPersistenceMode::BestEffort, metrics: None, lifecycle: LifecycleState::new(), readiness_probe_timeout_ms: 250, @@ -1120,6 +1246,7 @@ pub fn compute_state( policies: Arc::new(RwLock::new(policy_set)), http_client, audit_store, + audit_persistence_mode: AuditPersistenceMode::BestEffort, metrics: None, lifecycle: LifecycleState::new(), readiness_probe_timeout_ms: 250, diff --git a/guard-rail-engine/src/proxy/response.rs b/guard-rail-engine/src/proxy/response.rs index 1c6ff1f..ccc90b9 100644 --- a/guard-rail-engine/src/proxy/response.rs +++ b/guard-rail-engine/src/proxy/response.rs @@ -75,6 +75,20 @@ pub fn bad_gateway_response(execution_id: &str, message: &str) -> Response { .into_response() } +pub fn audit_persistence_error_response(execution_id: &str) -> Response { + let body = ErrorResponse { + status: "error".to_string(), + execution_id: execution_id.to_string(), + message: "Audit persistence failed".to_string(), + }; + ( + StatusCode::SERVICE_UNAVAILABLE, + [("x-guardrail-execution-id", execution_id.to_string())], + Json(body), + ) + .into_response() +} + pub fn method_not_allowed_response(execution_id: &str) -> Response { let body = RejectResponse { status: "rejected".to_string(), diff --git a/guard-rail-engine/src/reload.rs b/guard-rail-engine/src/reload.rs index 014caac..0548e1d 100644 --- a/guard-rail-engine/src/reload.rs +++ b/guard-rail-engine/src/reload.rs @@ -107,9 +107,6 @@ pub async fn apply_reload_candidate( crate::tenant::cache::validate_route_auth_state(&new_routes, &snapshot) .map_err(|err| format!("{err:?}"))?; - crate::tenant::cache::validate_all_routes_bound(&new_routes, &snapshot) - .map_err(|err| err.to_string())?; - crate::routes::RouteValidator::validate_upstream_security(&new_routes, environment) .map_err(|err| err.to_string())?; @@ -183,7 +180,7 @@ mod tests { use super::*; #[tokio::test] - async fn test_apply_reload_candidate_rejects_unbound_tenant_route() { + async fn test_apply_reload_candidate_allows_unbound_tenant_bound_route() { let routes = std::sync::Arc::new(tokio::sync::RwLock::new(RouteTable::from_routes(vec![]))); let policies = std::sync::Arc::new(tokio::sync::RwLock::new(PolicySet::from_policies(vec![]))); @@ -207,7 +204,43 @@ mod tests { ) .await; + assert!(result.is_ok()); + assert!(routes.read().await.lookup("tenant-route").is_some()); + } + + #[tokio::test] + async fn test_apply_reload_candidate_rejects_bound_public_route() { + let routes = std::sync::Arc::new(tokio::sync::RwLock::new(RouteTable::from_routes(vec![]))); + let policies = + std::sync::Arc::new(tokio::sync::RwLock::new(PolicySet::from_policies(vec![]))); + let tenant_cache = crate::tenant::cache::TenantAuthCache::default(); + let tenant_id = uuid::Uuid::new_v4(); + let mut snapshot = crate::tenant::cache::TenantAuthSnapshot::default(); + snapshot + .route_bindings + .insert("open-route".into(), tenant_id); + tenant_cache.replace(snapshot).await; + + let new_routes = RouteTable::from_routes(vec![crate::routes::Route::new( + "open-route".into(), + crate::routes::RouteAuthMode::Public, + "http://example.com".into(), + vec!["POST".into()], + vec![], + )]); + + let result = apply_reload_candidate( + new_routes, + PolicySet::from_policies(vec![]), + &routes, + &policies, + &tenant_cache, + crate::config::RuntimeEnvironment::Development, + ) + .await; + assert!(result.is_err()); + assert!(routes.read().await.lookup("open-route").is_none()); } #[tokio::test] @@ -229,7 +262,7 @@ mod tests { crate::routes::RouteAuthMode::TenantBound, "http://example.com/rejected".into(), vec!["POST".into()], - vec![], + vec!["missing-policy".into()], )]); let result = apply_reload_candidate( diff --git a/guard-rail-engine/tests/audit_api_test.rs b/guard-rail-engine/tests/audit_api_test.rs index 9fc9690..1ac49a4 100644 --- a/guard-rail-engine/tests/audit_api_test.rs +++ b/guard-rail-engine/tests/audit_api_test.rs @@ -128,6 +128,7 @@ policies: policies: std::sync::Arc::new(tokio::sync::RwLock::new(policy_set)), http_client: reqwest::Client::new(), audit_store: Some(store.clone()), + audit_persistence_mode: guard_rail_engine::config::AuditPersistenceMode::BestEffort, metrics: None, lifecycle: guard_rail_engine::shutdown::LifecycleState::new(), readiness_probe_timeout_ms: 250, @@ -221,6 +222,7 @@ async fn build_test_router_with_audit_store() -> AuditRouterHarness { )), http_client: reqwest::Client::new(), audit_store: Some(audit_store), + audit_persistence_mode: guard_rail_engine::config::AuditPersistenceMode::BestEffort, metrics: None, lifecycle: guard_rail_engine::shutdown::LifecycleState::new(), readiness_probe_timeout_ms: 250, @@ -549,6 +551,7 @@ routes: policies: std::sync::Arc::new(tokio::sync::RwLock::new(policies)), http_client: reqwest::Client::new(), audit_store: Some(store), + audit_persistence_mode: guard_rail_engine::config::AuditPersistenceMode::BestEffort, metrics: None, lifecycle: guard_rail_engine::shutdown::LifecycleState::new(), readiness_probe_timeout_ms: 250, diff --git a/guard-rail-engine/tests/auth_integration_test.rs b/guard-rail-engine/tests/auth_integration_test.rs index ea7527a..aba6284 100644 --- a/guard-rail-engine/tests/auth_integration_test.rs +++ b/guard-rail-engine/tests/auth_integration_test.rs @@ -208,6 +208,7 @@ routes: policies: std::sync::Arc::new(tokio::sync::RwLock::new(policies)), http_client: reqwest::Client::new(), audit_store: Some(store.clone()), + audit_persistence_mode: guard_rail_engine::config::AuditPersistenceMode::BestEffort, metrics: None, lifecycle: guard_rail_engine::shutdown::LifecycleState::new(), readiness_probe_timeout_ms: 250, diff --git a/guard-rail-engine/tests/integration_test.rs b/guard-rail-engine/tests/integration_test.rs index 8fd0245..4e29812 100644 --- a/guard-rail-engine/tests/integration_test.rs +++ b/guard-rail-engine/tests/integration_test.rs @@ -91,6 +91,7 @@ policies: policies: Arc::new(RwLock::new(policy_set)), http_client: Client::new(), audit_store: None, + audit_persistence_mode: guard_rail_engine::config::AuditPersistenceMode::BestEffort, metrics: None, lifecycle: guard_rail_engine::shutdown::LifecycleState::new(), readiness_probe_timeout_ms: 250, @@ -151,6 +152,7 @@ async fn build_test_router_without_audit_store() -> axum::Router { )), http_client: Client::new(), audit_store: None, + audit_persistence_mode: guard_rail_engine::config::AuditPersistenceMode::BestEffort, metrics: None, lifecycle: guard_rail_engine::shutdown::LifecycleState::new(), readiness_probe_timeout_ms: 250, diff --git a/guard-rail-engine/tests/replay_integration_test.rs b/guard-rail-engine/tests/replay_integration_test.rs index bdf3e4b..43dd673 100644 --- a/guard-rail-engine/tests/replay_integration_test.rs +++ b/guard-rail-engine/tests/replay_integration_test.rs @@ -128,6 +128,7 @@ async fn start_stage4_test_app() -> TestHarness { policies, http_client: reqwest::Client::new(), audit_store: Some(store.clone()), + audit_persistence_mode: guard_rail_engine::config::AuditPersistenceMode::BestEffort, metrics: None, lifecycle: guard_rail_engine::shutdown::LifecycleState::new(), readiness_probe_timeout_ms: 250, diff --git a/guard-rail-engine/tests/smoke_test.rs b/guard-rail-engine/tests/smoke_test.rs index a41d3c1..7f56e3b 100644 --- a/guard-rail-engine/tests/smoke_test.rs +++ b/guard-rail-engine/tests/smoke_test.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use tempfile::TempDir; use tokio::net::TcpListener; use tokio::sync::RwLock; +use tower::util::ServiceExt; async fn start_mock_upstream(status: u16, body: &'static str, delay_ms: u64) -> String { let app = Router::new().route( @@ -33,6 +34,72 @@ fn write_file(dir: &std::path::Path, name: &str, content: &str) { std::fs::write(dir.join(name), content).unwrap(); } +fn app_with_audit_mode( + mode: guard_rail_engine::config::AuditPersistenceMode, + audit_store: Option, +) -> axum::Router { + let route = guard_rail_engine::routes::Route::new( + "open-route".to_string(), + guard_rail_engine::routes::RouteAuthMode::Public, + "http://127.0.0.1:1/api/open".to_string(), + vec!["POST".to_string()], + vec![], + ); + + let state = guard_rail_engine::proxy::AppState { + routes: Arc::new(RwLock::new( + guard_rail_engine::routes::RouteTable::from_routes(vec![route]), + )), + policies: Arc::new(RwLock::new(guard_rail_engine::policy::PolicySet::new())), + http_client: Client::new(), + audit_store, + audit_persistence_mode: mode, + metrics: Some(Arc::new( + guard_rail_engine::observability::metrics::Metrics::new().unwrap(), + )), + lifecycle: guard_rail_engine::shutdown::LifecycleState::new(), + readiness_probe_timeout_ms: 250, + trace_header_name: "traceparent".to_string(), + route_config_hash: guard_rail_engine::audit::hash::hash_string("routes"), + policy_set_hash: guard_rail_engine::audit::hash::hash_string("policies"), + admin_token: "stage5-admin-token".to_string(), + tenant_repo: guard_rail_engine::tenant::repository::TenantRepository::new( + sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect_lazy("postgres://localhost:5432") + .unwrap(), + ), + tenant_cache: guard_rail_engine::tenant::cache::TenantAuthCache::default(), + rate_limiter: guard_rail_engine::auth::rate_limit::TenantRateLimiter::new(120, 30), + replay: guard_rail_engine::config::ReplayConfig::default(), + }; + + guard_rail_engine::proxy::build_main_router( + state, + 1_048_576, + &guard_rail_engine::config::ObservabilityConfig::default(), + ) +} + +async fn invalid_json_response_with_audit_mode( + mode: guard_rail_engine::config::AuditPersistenceMode, +) -> axum::response::Response { + let mut request = axum::http::Request::builder() + .method("POST") + .uri("/v1/execute/open-route") + .header("content-type", "application/json") + .body(axum::body::Body::from("{invalid json")) + .unwrap(); + request.extensions_mut().insert(axum::extract::ConnectInfo( + "127.0.0.1:3000".parse::().unwrap(), + )); + + app_with_audit_mode(mode, None) + .oneshot(request) + .await + .unwrap() +} + struct SmokeHarness { base_url: String, lifecycle: guard_rail_engine::shutdown::LifecycleState, @@ -90,6 +157,7 @@ routes: policies: Arc::new(RwLock::new(policies)), http_client: Client::new(), audit_store: None, + audit_persistence_mode: guard_rail_engine::config::AuditPersistenceMode::BestEffort, metrics: Some(Arc::clone(&metrics)), lifecycle: lifecycle.clone(), readiness_probe_timeout_ms: 250, @@ -178,6 +246,7 @@ routes: policies: Arc::new(RwLock::new(policies)), http_client: Client::new(), audit_store: None, + audit_persistence_mode: guard_rail_engine::config::AuditPersistenceMode::BestEffort, metrics: Some(Arc::clone(&metrics)), lifecycle: lifecycle.clone(), readiness_probe_timeout_ms: 250, @@ -224,6 +293,29 @@ routes: } } +#[tokio::test] +async fn test_required_audit_persistence_returns_503_when_store_unavailable() { + let response = invalid_json_response_with_audit_mode( + guard_rail_engine::config::AuditPersistenceMode::RequiredBeforeResponse, + ) + .await; + + assert_eq!( + response.status(), + axum::http::StatusCode::SERVICE_UNAVAILABLE + ); +} + +#[tokio::test] +async fn test_best_effort_audit_persistence_preserves_response_when_store_unavailable() { + let response = invalid_json_response_with_audit_mode( + guard_rail_engine::config::AuditPersistenceMode::BestEffort, + ) + .await; + + assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST); +} + #[tokio::test] async fn test_ready_endpoint_returns_503_until_runtime_ready() { let harness = start_harness(false).await; diff --git a/package-lock.json b/package-lock.json index bb466e9..b960866 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "guard-rail", "version": "0.1.0", "dependencies": { - "next": "16.2.2", + "next": "16.2.4", "react": "19.2.4", "react-dom": "19.2.4" }, @@ -18,7 +18,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "16.2.2", + "eslint-config-next": "16.2.4", "tailwindcss": "^4", "typescript": "^5" } @@ -1035,15 +1035,15 @@ } }, "node_modules/@next/env": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.2.tgz", - "integrity": "sha512-LqSGz5+xGk9EL/iBDr2yo/CgNQV6cFsNhRR2xhSXYh7B/hb4nePCxlmDvGEKG30NMHDFf0raqSyOZiQrO7BkHQ==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz", + "integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.2.tgz", - "integrity": "sha512-IOPbWzDQ+76AtjZioaCjpIY72xNSDMnarZ2GMQ4wjNLvnJEJHqxQwGFhgnIWLV9klb4g/+amg88Tk5OXVpyLTw==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.4.tgz", + "integrity": "sha512-tOX826JJ96gYK/go18sPUgMq9FK1tqxBFfUCEufJb5XIkWFFmpgU7mahJANKGkHs7F41ir3tReJ3Lv5La0RvhA==", "dev": true, "license": "MIT", "dependencies": { @@ -1051,9 +1051,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.2.tgz", - "integrity": "sha512-B92G3ulrwmkDSEJEp9+XzGLex5wC1knrmCSIylyVeiAtCIfvEJYiN3v5kXPlYt5R4RFlsfO/v++aKV63Acrugg==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz", + "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==", "cpu": [ "arm64" ], @@ -1067,9 +1067,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.2.tgz", - "integrity": "sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz", + "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==", "cpu": [ "x64" ], @@ -1083,9 +1083,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.2.tgz", - "integrity": "sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz", + "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==", "cpu": [ "arm64" ], @@ -1099,9 +1099,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.2.tgz", - "integrity": "sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz", + "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==", "cpu": [ "arm64" ], @@ -1115,9 +1115,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.2.tgz", - "integrity": "sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz", + "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==", "cpu": [ "x64" ], @@ -1131,9 +1131,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.2.tgz", - "integrity": "sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz", + "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==", "cpu": [ "x64" ], @@ -1147,9 +1147,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.2.tgz", - "integrity": "sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz", + "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==", "cpu": [ "arm64" ], @@ -1163,9 +1163,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.2.tgz", - "integrity": "sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz", + "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==", "cpu": [ "x64" ], @@ -3111,13 +3111,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.2.tgz", - "integrity": "sha512-6VlvEhwoug2JpVgjZDhyXrJXUEuPY++TddzIpTaIRvlvlXXFgvQUtm3+Zr84IjFm0lXtJt73w19JA08tOaZVwg==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.4.tgz", + "integrity": "sha512-A6ekXYFj/YQxBPMl45g3e+U8zJo+X2+ZQwcz34pPKjpc/3S4roBA2Rd9xWB4FKuSxhofo1/95WjzmUY+wHrOhg==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.2.2", + "@next/eslint-plugin-next": "16.2.4", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -4961,12 +4961,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.2.tgz", - "integrity": "sha512-i6AJdyVa4oQjyvX/6GeER8dpY/xlIV+4NMv/svykcLtURJSy/WzDnnUk/TM4d0uewFHK7xSQz4TbIwPgjky+3A==", + "version": "16.2.4", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz", + "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==", "license": "MIT", "dependencies": { - "@next/env": "16.2.2", + "@next/env": "16.2.4", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -4980,14 +4980,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.2", - "@next/swc-darwin-x64": "16.2.2", - "@next/swc-linux-arm64-gnu": "16.2.2", - "@next/swc-linux-arm64-musl": "16.2.2", - "@next/swc-linux-x64-gnu": "16.2.2", - "@next/swc-linux-x64-musl": "16.2.2", - "@next/swc-win32-arm64-msvc": "16.2.2", - "@next/swc-win32-x64-msvc": "16.2.2", + "@next/swc-darwin-arm64": "16.2.4", + "@next/swc-darwin-x64": "16.2.4", + "@next/swc-linux-arm64-gnu": "16.2.4", + "@next/swc-linux-arm64-musl": "16.2.4", + "@next/swc-linux-x64-gnu": "16.2.4", + "@next/swc-linux-x64-musl": "16.2.4", + "@next/swc-win32-arm64-msvc": "16.2.4", + "@next/swc-win32-x64-msvc": "16.2.4", "sharp": "^0.34.5" }, "peerDependencies": { diff --git a/package.json b/package.json index 119baee..6bd3966 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,10 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint app next.config.ts postcss.config.mjs eslint.config.mjs" }, "dependencies": { - "next": "16.2.2", + "next": "16.2.4", "react": "19.2.4", "react-dom": "19.2.4" }, @@ -19,7 +19,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "16.2.2", + "eslint-config-next": "16.2.4", "tailwindcss": "^4", "typescript": "^5" }