diff --git a/.coverage-gates.yml b/.coverage-gates.yml index 4d60740..550f23a 100644 --- a/.coverage-gates.yml +++ b/.coverage-gates.yml @@ -8,6 +8,12 @@ include: - internal/ - pkg/ +# Build-time code generators (run via `go generate`, validated by the +# docs-gen freshness test, not by line coverage) — excluded like cmd/. +exclude: + - internal/config/gen + - internal/service/gen + packages: internal/app: 85 internal/config: 88 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13c116d..db52e22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -322,13 +322,32 @@ jobs: docker logs "$cid" exit 1 + # --------------------------------------------------------------------------- + # 8. Docs drift — docs may not name an env/RPC/metric/path that does not + # exist, and the generated reference JSON must be up to date. + # --------------------------------------------------------------------------- + docs-drift: + name: Docs drift + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + + - name: go test ./internal/docscheck/... + run: go test -count=1 ./internal/docscheck/... + # --------------------------------------------------------------------------- # Aggregate gate — easy single required check on `main`. # --------------------------------------------------------------------------- ci: name: CI runs-on: ubuntu-latest - needs: [lint, tidy, vuln, test, smoke, integration, fuzz, docker] + needs: [lint, tidy, vuln, test, smoke, integration, fuzz, docker, docs-drift] if: always() steps: - name: All required jobs succeeded diff --git a/Makefile b/Makefile index 7cc62b7..e7877e9 100644 --- a/Makefile +++ b/Makefile @@ -177,6 +177,15 @@ fuzz: ## Fuzz smoke — runs each fuzz target with seed corpus + 15s fuzzing CONNECT_OPENAPI_VERSION ?= v0.18.0 +.PHONY: docs-gen +docs-gen: ## Regenerate the docs reference JSON (config + audit/metrics) from code + $(GO) generate ./internal/config/... ./internal/service/... + @echo "==> docs reference JSON regenerated → docs-site/src/data/generated" + +.PHONY: docs-drift +docs-drift: ## Check docs name only real identifiers + generated JSON is fresh + $(GO) test -count=1 ./internal/docscheck/... + .PHONY: proto proto: ## Regenerate Go stubs + OpenAPI + proto reference from proto $(BUF) generate diff --git a/docs-site/src/data/generated/audit-events.json b/docs-site/src/data/generated/audit-events.json new file mode 100644 index 0000000..bb7e49a --- /dev/null +++ b/docs-site/src/data/generated/audit-events.json @@ -0,0 +1,54 @@ +{ + "events": [ + { + "name": "create_project", + "kind": "AdminAction", + "const": "AdminActionCreateProject", + "description": "AdminActionCreateProject records a CreateProject mutation." + }, + { + "name": "delete", + "kind": "TupleOpKind", + "const": "TupleOpDelete", + "description": "TupleOpDelete is a revocation (a tuple was removed)." + }, + { + "name": "insert", + "kind": "TupleOpKind", + "const": "TupleOpInsert", + "description": "TupleOpInsert is a grant (a tuple was written)." + }, + { + "name": "update_project", + "kind": "AdminAction", + "const": "AdminActionUpdateProject", + "description": "AdminActionUpdateProject records an UpdateProject mutation." + } + ], + "metrics": [ + { + "name": "authz_batchcheck_items", + "description": "Items per BatchCheck request." + }, + { + "name": "authz_check_decisions_total", + "description": "Authorization Check/CheckSet/BatchCheck decisions by namespace, relation, and outcome." + }, + { + "name": "authz_check_duration_seconds", + "description": "Authorization decision RPC latency in seconds." + }, + { + "name": "authz_decision_errors_total", + "description": "Authorization decision RPC errors (validation + internal)." + }, + { + "name": "authz_eval_backstop_total", + "description": "Engine per-request safety backstops that fired, by reason: depth/cycle (graceful fail-closed deny) or budget (read-budget exhausted, ResourceExhausted error). A rising rate signals an abusive tenant or a misconfigured deep/cyclic model." + }, + { + "name": "authz_region_refused_total", + "description": "Requests refused because the project's pinned data region differs from this instance's region (data-residency fail-closed)." + } + ] +} diff --git a/docs-site/src/data/generated/config.json b/docs-site/src/data/generated/config.json new file mode 100644 index 0000000..57da231 --- /dev/null +++ b/docs-site/src/data/generated/config.json @@ -0,0 +1,139 @@ +[ + { + "name": "GATEWAY_ADMIN_API_SECRET", + "type": "string", + "default": "", + "required": false, + "description": "AdminAPISecret gates the AdminService (project configuration), presented as the `X-Admin-Secret` header. Empty disables the admin RPCs entirely.", + "constraint": "must be a high-entropy value of at least 32 characters" + }, + { + "name": "GATEWAY_ADMIN_RATE_LIMIT_PER_MINUTE", + "type": "int", + "default": "30", + "required": false, + "description": "AdminRateLimitPerMinute throttles the admin API per caller (online brute-force protection); non-positive disables the limiter." + }, + { + "name": "GATEWAY_ALLOWED_ORIGINS", + "type": "csv", + "default": "", + "required": false, + "description": "AllowedOrigins is the CORS allow-list of browser origins permitted to call the API. Empty allows none." + }, + { + "name": "GATEWAY_AUDIT_LOG", + "type": "bool", + "default": "false", + "required": false, + "description": "AuditLog enables an async, append-only audit log of every relation-tuple change and admin mutation (to the structured logger). Default false." + }, + { + "name": "GATEWAY_CONNECT_PORT", + "type": "int", + "default": "8080", + "required": false, + "description": "ConnectPort is the Connect/HTTP listener serving JSON, gRPC, and gRPC-Web RPCs." + }, + { + "name": "GATEWAY_DATA_REGION", + "type": "string", + "default": "", + "required": false, + "description": "DataRegion is the region this instance serves: it refuses to operate on a project pinned to a different data_region (fail closed). Empty (default) is region-agnostic — serves every project, today's behavior." + }, + { + "name": "GATEWAY_DECISION_LOG", + "type": "bool", + "default": "false", + "required": false, + "description": "DecisionLog enables an async, append-only audit log of every Check/ CheckSet decision (to the structured logger). Default false." + }, + { + "name": "GATEWAY_DEFAULT_PROJECT_ID", + "type": "string", + "default": "default", + "required": false, + "description": "DefaultProjectID is the project shard used for any request whose project_id is empty." + }, + { + "name": "GATEWAY_DEFAULT_TENANT_ID", + "type": "string", + "default": "", + "required": false, + "description": "DefaultTenantID is the tenant pinned for requests that omit tenant_id (the project's default tenant). Empty is the conventional default." + }, + { + "name": "GATEWAY_HTTP_MAX_BODY_BYTES", + "type": "int", + "default": "1048576", + "required": false, + "description": "HTTPMaxBodyBytes is the maximum accepted request body, in bytes. Must be positive.", + "constraint": "must be positive" + }, + { + "name": "GATEWAY_MAX_BATCH_CHECK_ITEMS", + "type": "int", + "default": "1000", + "required": false, + "description": "MaxBatchCheckItems caps the number of items in a single BatchCheck request, bounding per-request cost." + }, + { + "name": "GATEWAY_MAX_CHECK_READS", + "type": "int", + "default": "5000", + "required": false, + "description": "MaxCheckReads caps the number of store reads (tuple lookups) a single Check/CheckSet/Expand/ListObjects evaluation may perform, bounding the per-request cost a pathological cyclic/branching graph can inflict. Non-positive uses the service default.", + "constraint": "when set, must be \u003e= 100 (use 0/negative for the default)" + }, + { + "name": "GATEWAY_MAX_EXPAND_NODES", + "type": "int", + "default": "10000", + "required": false, + "description": "MaxExpandNodes caps the size of an Expand result tree, bounding the response a single cheap request can amplify into." + }, + { + "name": "GATEWAY_MAX_LIST_OBJECTS", + "type": "int", + "default": "1000", + "required": false, + "description": "MaxListObjects caps the candidate set a single ListObjects call scans, bounding its full-scan + per-object Check cost." + }, + { + "name": "GATEWAY_METRICS_PORT", + "type": "int", + "default": "9090", + "required": false, + "description": "MetricsPort serves the Prometheus /metrics endpoint and the health probes." + }, + { + "name": "GATEWAY_POSTGRES_AUTO_MIGRATE", + "type": "bool", + "default": "false", + "required": false, + "description": "PostgresAutoMigrate runs the expand migration on boot when true. It defaults to FALSE: migrations are a deliberate operator step (out-of-band `workspace migrate`, an init container, or a migrate Job), so a large existing DB's first deploy can never livelock on a bounded CONCURRENTLY build inside the boot window. Opt in (true) only for small/dev DBs." + }, + { + "name": "GATEWAY_POSTGRES_DSN", + "type": "string", + "default": "", + "required": false, + "description": "PostgresDSN selects the postgres driver when set; empty uses memory." + }, + { + "name": "GATEWAY_SERVICE_AUTH_TOKENS", + "type": "csv", + "default": "", + "required": false, + "description": "ServiceAuthTokens are the accepted service credentials presented by calling backends as `Authorization: Bearer \u003ctoken\u003e`. This is an internal service authenticated service-to-service (not by end-user tokens); the user is passed as data in the request. Empty disables the requirement — trust the network/mesh — and the service logs a warning." + }, + { + "name": "GATEWAY_TENANT_RATE_LIMIT_PER_MINUTE", + "type": "int", + "default": "0", + "required": false, + "description": "TenantRateLimitPerMinute throttles authz data-plane RPCs per (project, tenant); non-positive (the default) disables the limiter.", + "constraint": "when enabled, must be \u003e= 60 (use 0 to disable)" + } +] diff --git a/docs-site/src/pages/docs/deployment/docker.astro b/docs-site/src/pages/docs/deployment/docker.astro index 02583e0..f4ea38c 100644 --- a/docs-site/src/pages/docs/deployment/docker.astro +++ b/docs-site/src/pages/docs/deployment/docker.astro @@ -28,9 +28,13 @@ docker compose down -v # stop and wipe the postgres volume`} title="docker compose" />

- With GATEWAY_POSTGRES_AUTO_MIGRATE=true (the default) the - schema is applied on first connect, so the stack is ready as soon as - Postgres is healthy. + Migrations are a deliberate operator step: + GATEWAY_POSTGRES_AUTO_MIGRATE defaults to false, + so apply the schema explicitly with workspace migrate (an init + container or a one-shot job) before serving, or set it to true + for a small dev database to migrate on first connect. See + Configuration for the + full knob reference.

A Compose snippet

diff --git a/docs-site/src/pages/docs/installation/configuration.astro b/docs-site/src/pages/docs/installation/configuration.astro index 194e0d2..0666721 100644 --- a/docs-site/src/pages/docs/installation/configuration.astro +++ b/docs-site/src/pages/docs/installation/configuration.astro @@ -1,8 +1,27 @@ --- import DocsLayout from "../../../layouts/DocsLayout.astro"; import { Code } from "astro-expressive-code/components"; +import configKnobs from "../../../data/generated/config.json"; const base = import.meta.env.BASE_URL.replace(/\/$/, "") + "/"; + +// The table below is generated from internal/config/config.go by +// `make docs-gen` — never hand-edit names, defaults, or constraints here. +// A CI drift check (internal/docscheck) fails if this JSON is stale. +type Knob = { + name: string; + type: string; + default: string; + required: boolean; + description: string; + constraint?: string; +}; +const knobs = configKnobs as Knob[]; + +function renderDefault(k: Knob): string { + if (k.type === "csv" || k.default === "") return "(empty)"; + return k.default; +} --- @@ -15,73 +34,66 @@ const base = import.meta.env.BASE_URL.replace(/\/$/, "") + "/"; zero configuration for local development; production deployments override the Postgres DSN and the service-auth tokens.

- -

Ports

- - - - - - - - -
VariablePurposeDefault
GATEWAY_CONNECT_PORTConnect/HTTP listener — JSON, gRPC, and gRPC-Web RPCs.8080
GATEWAY_METRICS_PORTPrometheus /metrics and the health probes.9090
- -

Project

- - - - - - - -
VariablePurposeDefault
GATEWAY_DEFAULT_PROJECT_IDProject shard used for any request whose project_id is empty.default

- Every workspace, group, invitation, and relation tuple is scoped to a - project (the isolation shard). One B2C product is typically one project; a - B2B platform shards per customer. See - Projects & Multi-Tenancy. + The reference table is generated directly from the service's configuration + source (internal/config/config.go), so the variable names, + defaults, and validation floors here can never drift from the code.

-

Storage

+

All variables

- + + + + + + + - - + { + knobs.map((k) => ( + + + + + + + + )) + }
VariablePurposeDefault
VariableTypeDefaultDescriptionConstraint
GATEWAY_POSTGRES_DSNPostgres connection string. Selects the Postgres driver. Empty selects the in-memory store.(empty)
GATEWAY_POSTGRES_AUTO_MIGRATEApply pending migrations automatically on connect.true
+ {k.name} + {k.type} + {renderDefault(k) === "(empty)" ? ( + (empty) + ) : ( + {renderDefault(k)} + )} + {k.description}{k.constraint ? k.constraint : }
-

Choosing a store: memory vs. Postgres

+

Choosing a store: memory vs. Postgres

Service authentication

- - - - - - - -
VariablePurposeDefault
GATEWAY_SERVICE_AUTH_TOKENSComma-separated list of accepted service credentials. A caller presents one as Authorization: Bearer <token>. Empty disables the requirement and logs a warning — every caller is then trusted.(empty)

Workspaces is an internal service-to-service API: the caller is a trusted product backend, and the end user is passed as data @@ -104,26 +116,6 @@ const base = import.meta.env.BASE_URL.replace(/\/$/, "") + "/"; Security & Service Auth. -

CORS

- - - - - - - -
VariablePurposeDefault
GATEWAY_ALLOWED_ORIGINSComma-separated list of CORS origins allowed to call the API from a browser. Empty allows none.(empty)
- -

Request body size

- - - - - - - -
VariablePurposeDefault
GATEWAY_HTTP_MAX_BODY_BYTESMaximum accepted request body, in bytes. Must be positive.1048576 (1 MiB)
-

A production example

The minimum production override: a durable store and at least one service diff --git a/docs-site/src/pages/docs/operations/observability.astro b/docs-site/src/pages/docs/operations/observability.astro index 7cac37a..e8367c6 100644 --- a/docs-site/src/pages/docs/operations/observability.astro +++ b/docs-site/src/pages/docs/operations/observability.astro @@ -1,8 +1,22 @@ --- import DocsLayout from "../../../layouts/DocsLayout.astro"; import { Code } from "astro-expressive-code/components"; +import auditData from "../../../data/generated/audit-events.json"; const base = import.meta.env.BASE_URL.replace(/\/$/, "") + "/"; + +// The metric and audit-event tables below are generated from +// internal/connect/metrics.go and internal/service/auditlog.go by +// `make docs-gen`; a CI drift check fails if this JSON is stale. +type Metric = { name: string; description: string }; +type AuditEvent = { + name: string; + kind: string; + const: string; + description: string; +}; +const metrics = auditData.metrics as Metric[]; +const auditEvents = auditData.events as AuditEvent[]; --- @@ -60,6 +74,57 @@ curl -i http://localhost:8080/readyz # readiness`} title="prometheus.yml" /> +

Exported metrics

+

+ The authorization decision metrics the service exports. Labels are kept to + low-cardinality dimensions (namespace, relation, outcome, RPC, reason) — + never object or subject identifiers. This list is generated from the + service's metrics source, so it stays in lockstep with the code. +

+ + + + + + { + metrics.map((m) => ( + + + + + )) + } + +
MetricMeaning
+ {m.name} + {m.description}
+ +

Audit events

+

+ With GATEWAY_AUDIT_LOG=true, every relation-tuple change and + admin mutation is emitted to the structured logger as an append-only audit + record. The event-type vocabulary — the operation strings carried by those + records — is generated from the service's audit source: +

+ + + + + + { + auditEvents.map((e) => ( + + + + + + )) + } + +
EventKindMeaning
+ {e.name} + {e.kind}{e.description}
+

Structured logs

Workspaces logs with zap as diff --git a/docs-site/src/pages/docs/quickstart.astro b/docs-site/src/pages/docs/quickstart.astro index 93a2787..98e1704 100644 --- a/docs-site/src/pages/docs/quickstart.astro +++ b/docs-site/src/pages/docs/quickstart.astro @@ -22,9 +22,9 @@ const base = import.meta.env.BASE_URL.replace(/\/$/, "") + "/"; your product backend will present; here we use a single token.

- Pending migrations apply automatically on first connect while - GATEWAY_POSTGRES_AUTO_MIGRATE=true (the default). Export the - service token so the curls below can reuse it: + The bundled docker-compose.yml sets + GATEWAY_POSTGRES_AUTO_MIGRATE=true so the dev stack migrates on + first connect (it defaults to false — migrations are an + explicit operator step in production). Export the service token so the curls + below can reuse it: