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). |