Skip to content

Data gateway API

Complete HTTP reference for the data gateway — the sole data path for apps. It authenticates the calling app, evaluates the App CR's declared scopes per request, denies undeclared access, fails closed, and logs every decision. For the workflow see declare and change data scopes; for the internals see data gateway components.

Sources of truth: specs/003-governance-depth/contracts/data-gateway-api.md and services/data-gateway/ (handler, gateway, auth, logstore, connectors).

Deployment facts

Property Value
Namespace tend-system
Service data-gateway.tend-system.svc, port 8080 (ClusterIP)
Access-log store Embedded SQLite (WAL, append-only) on the gateway's PVC, at $DATA_GATEWAY_DATA_DIR/access-log.db
Network restriction Cilium policy restricts app-facing callers to app namespaces (ADR-0007)

Authentication

Two caller surfaces with structurally different credentials:

Surface Credential Validation
App-facing (/v1/sources/…) Projected ServiceAccount token, audience tend-data-gateway, sent as Authorization: Bearer <token> Kubernetes TokenReview (authentication.k8s.io/v1) — the API server owns expiry/revocation. The reviewed subject system:serviceaccount:<ns>:<sa> resolves to a namespace, which maps to the owning App CR. No per-app secrets exist. Verdicts are cached ≤2 minutes, keyed by a hash of the raw token.
Platform-facing (/v1/logs…) Keycloak OIDC JWT (RS256 via JWKS), audience tend-data-gateway-logs, issuer OIDC_ISSUER Local JWT validation (issuer, audience, expiry, signature). An app's projected SA token is not a JWT this validator will ever accept (wrong issuer, wrong signing key), so apps are structurally rejected from the query API with 403 — no separate app-token detection logic exists. A missing bearer token is 401; a present-but-invalid token is 403.

Decision semantics

Every app-facing request is evaluated before any connector is touched. Every row below produces exactly one AccessLogRecord, written before the response is sent.

One refusal is not logged as a refusal

POST /v1/sources/{name}/query is gated at the coarse read level, so the scope check writes an allow record before the connector runs. The postgres connector's own statement classifier then makes the finer distinction, and a statement it refuses returns 403 insufficient-access with no second record. The access log therefore shows that request as decision=allow, requestedAccess=read even though the statement was refused. The refusal is real; the log is coarser than the decision. If the log write itself fails,

the request is downgraded to a 503 deny regardless of what the decision would have been ("can't log → don't serve").

Situation Response Logged decision / reason
Declared scope covers source + access 2xx, request served allow
Source not in the App's dataScopes 403 {"error":"undeclared-source"} deny / undeclared-source
Scope declared with access: read but the operation writes 403 {"error":"insufficient-access"} deny / insufficient-access
Token missing or invalid 401 (no body) deny / unauthenticated (identity empty)
Scope name is on the platform revoked-scopes baseline — checked before the declaration match, so revocation overrides a declared scope 403 {"error":"scope-revoked"} deny / scope revoked
Scope evaluation, App lookup, or internal error — fail closed 503 {"error":"evaluator-error"} deny / evaluator-error
Access-log write failed 503 {"error":"evaluator-error"} none possible — that is why it fails closed

A namespace whose pods authenticate but has no owning App CR is treated as a platform inconsistency, not a scope decision: 503 evaluator-error.

The handler derives the required access from the route: GET needs read, POST/PUT need readwrite. The postgres route is gated at read (the minimum any query needs); the connector's statement classifier makes the finer distinction (below).

App-facing data API

A source is addressed by declared scope name: /v1/sources/{name}/…. The connector sub-API depends on the scope's sourceType (see App CRD — DataScope). A scope that is declared but has no platform-configured connector target returns 404 {"error":"source not configured: <name>"}.

sourceType: synthetic

Endpoint Access Response
GET /v1/sources/{name}/rows read 200 {"rows":[…]}
POST /v1/sources/{name}/rows readwrite 201 {"row":{…}}
GET /v1/sources/{name}/rows/{id} read 200 {"row":{…}}; 404 if the row does not exist

The POST body is {"data": {…}} — a non-empty string-to-string object, limited to 64 KiB. This API mirrors 001's synthetic-data service 1:1 so app cutover was a base-URL change; the 001 service retired at cutover.

sourceType: postgres

Endpoint Access Response
POST /v1/sources/{name}/query read at the gate; statement-classified after 200 {"columns":[…], "rows":[[…]]}

Body: {"statement": "SELECT …", "params": […]}, limited to 1 MiB. The statement must be non-empty.

Statement rules, enforced by the connector's classifier:

Statement class access: read access: readwrite
Read-only (SELECT, read-only CTEs, …) allowed allowed
DML (INSERT/UPDATE/DELETE, CTE-wrapped writes) 403 insufficient-access allowed
DDL (CREATE/ALTER/DROP/TRUNCATE/COMMENT/REINDEX/CLUSTER, …) always refused always refused
DCL (GRANT/REVOKE) always refused always refused
Unclassifiable / multi-statement batch refused refused

The classifier fails closed: anything it cannot confidently place in the read-only bucket is treated as a write, and every arm of a WITH (common table expression) statement is inspected — one writing arm makes the whole statement a write. Schema is not app territory: DDL is refused regardless of declared access.

sourceType: s3

Endpoint Access Response
GET /v1/sources/{name}/objects?prefix=… read 200 {"objects":[{"key","size","modified"}]}
GET /v1/sources/{name}/objects/{key} read 200, object bytes streamed (application/octet-stream); 404 if absent
PUT /v1/sources/{name}/objects/{key} readwrite 201

Connector target configuration

Which Postgres DSN, S3 bucket, or synthetic store a scope name maps to — and the gateway's own credentials to reach it — is platform configuration delivered via GitOps (DATA_GATEWAY_SOURCES_CONFIG, default /etc/data-gateway/sources.yaml), never app-supplied. Credentials are injected into the gateway at runtime; apps never hold data-source credentials at all.

Platform-facing query API

Feeds the evidence index (see evidence & estate API) and direct audit queries (see query an app's evidence). Requires a platform OIDC token (above).

GET /v1/logs

Query parameter Required Meaning
app yes namespace/name of the App; 400 when missing. Per-app queryability is the contract.
decision no allow | deny.
source no Scope/source name.
from, to no RFC3339 timestamps; invalid values are 400.
limit no Page size. Omitted → 100. Non-positive or non-numeric → 400. Values above 1000 fall back to 100.
cursor no Opaque pagination cursor from a prior page.

Response: 200 {"records":[AccessLogRecord…], "nextCursor":"…"} — newest-first; nextCursor is empty when no further page exists. Records are retained queryable for the app's lifetime; retirement-time archiving is Phase 3.

GET /v1/logs/summary

Query parameter Required Meaning
app yes namespace/name; 400 when missing.

Response: 200 {"allows": n, "denies": n, "lastDeny": {…}} — the cheap denial-visibility path (lastDeny is null when no deny exists). SC-002: a denial is queryable within 5 minutes; in practice immediately.

If the platform validator is not configured, both endpoints return 503.

Health endpoints

Endpoint Behavior
GET /healthz Liveness; always 200 ok.
GET /readyz Readiness, gated on the access-log store being writable (a real write + rollback against a heartbeat row, so probing never pollutes the log). A gateway that cannot log does not serve — fail closed. 503 otherwise.

Configuration

All configuration is environment variables; no secrets are embedded.

Variable Default Meaning
LISTEN_ADDR :8080 HTTP listen address.
DATA_GATEWAY_DATA_DIR /var/lib/data-gateway Directory (a PVC in cluster deployments) holding the SQLite access-log store (access-log.db).
DATA_GATEWAY_SOURCES_CONFIG /etc/data-gateway/sources.yaml Platform-delivered connector target config.
DATA_GATEWAY_TOKENREVIEW_CACHE_TTL 2m App-token TokenReview verdict cache TTL (contract: ≤2 minutes).
OIDC_ISSUER Keycloak realm issuer URL trusted for platform callers on the query API.
OIDC_AUDIENCE tend-data-gateway-logs Required aud claim on platform-caller tokens.
OIDC_JWKS_URL derived: <issuer>/protocol/openid-connect/certs JWKS endpoint for platform token validation.

AccessLogRecord

One record per gateway decision — the production half of the evidence chain (see the evidence chain). Owned by the data gateway; never stored on the App CR; exposed only via the query API above.

Field Type Meaning
id string Store-assigned, roughly monotonic (time-prefixed + random suffix).
app string namespace/name resolved from the presented identity.
identity string ServiceAccount subject from the projected token, e.g. system:serviceaccount:tend-app-x:default. Empty on unauthenticated denies.
source string Scope/source name addressed.
sourceType enum synthetic | postgres | s3.
requestedAccess enum read | readwrite — what the operation required.
scopeEvaluated string The declared scope matched; empty on deny-undeclared.
decision enum allow | deny — both outcomes are logged.
reason string Empty on allow; undeclared-source, insufficient-access, unauthenticated, or evaluator-error on deny.
at timestamp Decision time (RFC3339).