Connector Endpoints
External data source connectors — catalog CRUD, credential custody, read-only probes, and schema discovery (R44a), plus exposure authoring: parameterized, sqlglot-validated, hash-pin-approved SELECT queries with capped preview (R44b). Served by the connector-service (port 8011) and proxied through the API gateway at http://localhost:8000/api/v1/connectors*.
Endpoints
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /connectors | exposures.propose | List the tenant’s connectors |
POST | /connectors | connectors.manage | Register a connector (201) |
GET | /connectors/engines | exposures.propose | Engine metadata (capabilities, config JSON schema, credential fields) |
GET | /connectors/{key} | exposures.propose | Fetch one connector |
PATCH | /connectors/{key} | connectors.manage | Update display/config fields (config change → re-probe) |
DELETE | /connectors/{key} | connectors.manage | Remove a connector (204; 409 if it has approved exposures) |
POST | /connectors/{key}/disable | connectors.manage | Explicit disable |
POST | /connectors/{key}/enable | connectors.manage | Re-enable — always through a fresh probe |
PUT | /connectors/{key}/credential | connectors.manage | Set the credential (write-once payload; triggers probe + discovery-on-pass) |
POST | /connectors/{key}/probe | connectors.manage | Re-run the read-only probe |
POST | /connectors/{key}/discover | connectors.manage | Re-run schema discovery |
GET | /connectors/{key}/schema | exposures.propose | Current schema snapshot |
POST | /connectors/validate | exposures.propose | Validate SQL (shared validator as a service — live UI errors) |
GET | /connectors/{key}/exposures | exposures.propose | List exposures (?status= filter) |
POST | /connectors/{key}/exposures | exposures.propose | Create a draft exposure (201) |
GET | /connectors/{key}/exposures/{ekey} | exposures.propose | Fetch one exposure |
PATCH | /connectors/{key}/exposures/{ekey} | exposures.propose | Edit content (reverts to draft, re-hashes) |
DELETE | /connectors/{key}/exposures/{ekey} | exposures.propose | Delete (204; 409 if approved — retire first) |
POST | /connectors/{key}/exposures/{ekey}/submit | exposures.propose | draft → pending_review |
POST | /connectors/{key}/exposures/{ekey}/approve | exposures.approve | Approve — pins approved_sql_hash |
POST | /connectors/{key}/exposures/{ekey}/reject | exposures.approve | pending_review → rejected |
POST | /connectors/{key}/exposures/{ekey}/retire | exposures.approve | Retire an approved/pending exposure |
POST | /connectors/{key}/exposures/{ekey}/preview | exposures.propose | Capped preview against the live source |
Permissions map to roles as: connectors.manage = admin; exposures.propose = admin + power_user; exposures.approve = admin. Operators and reviewers have no connector surface. Enforcement is unconditional — 401/403 regardless of AEGIS_AUTH_MODE.
Three endpoints are service-internal (R44c) — the gateway returns 404 Not Found and they take the tenant from the X-Tenant-Id header only, with 401 for a header-less caller: GET /connectors/{key}/status (registry existence/active/verified check), GET /connectors/{key}/bindings (bulk-describe approved exposures for tool-schema building), and POST /connectors/{key}/exposures/{ekey}/execute (the agent execute path). Only the routes in the table above are client-reachable. POST /connectors/validate and the client-facing POST …/preview are reachable; only these three internal paths are guarded (matched by path segment status/bindings/execute).
Connector status lifecycle: unconfigured → probing → active | probe_failed, plus disabled via explicit disable (enable re-probes, never straight to active).
POST /connectors
Register a connector. The config is validated against the engine’s typed schema, and the destination is egress-validated before any row exists — a private/denied destination is rejected typed and audited.
curl -X POST http://localhost:8000/api/v1/connectors \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"connector_key": "operator_prod_db",
"display_name": "Operator Production DB",
"description": "Field production and allocation data",
"connector_type": "postgres",
"config": {"host": "db.operator.example.com", "port": 5432, "database": "production", "sslmode": "verify-full"},
"data_classification": "internal"
}'Per-type config:
| Engine | Fields |
|---|---|
postgres | host, port (default 5432), database, sslmode (disable | require | verify-ca | verify-full) |
snowflake | account (locator, e.g. atvgpmo-nk51631), warehouse, database, schema, role (optional) |
Response (201):
{
"connector_key": "operator_prod_db",
"display_name": "Operator Production DB",
"description": "Field production and allocation data",
"connector_type": "postgres",
"config": {"host": "db.operator.example.com", "port": 5432, "database": "production", "sslmode": "verify-full"},
"status": "unconfigured",
"has_credential": false,
"read_only_verified_at": null,
"last_probe": null,
"data_classification": "internal",
"created_at": "2026-07-18T10:00:00Z",
"updated_at": "2026-07-18T10:00:00Z"
}Errors — 422 with a typed detail.error:
| Error | Meaning |
|---|---|
config_invalid | Config fails the engine’s schema (per-field messages) |
egress_denied | Destination resolves to a denied range (loopback, link-local, RFC1918, CGNAT, …); also audited as connector.egress_denied |
resolution_failed | Hostname did not resolve |
409 — connector_key already exists for the tenant. connector_key must be a lowercase slug ([a-z][a-z0-9_-]{0,63}) — it becomes part of the secret name and later tool names.
GET /connectors/engines
Engine metadata for the register modal and capability display.
curl http://localhost:8000/api/v1/connectors/engines \
-H "Authorization: Bearer $TOKEN"Response (200): one entry per engine (postgres, snowflake), each with a capabilities map (how the engine enforces read-only, egress pinning, timeouts), the config_schema (JSON Schema for the config object), and credential_fields (e.g. ["password", "username"] for postgres, ["private_key", "username"] for snowflake).
PUT /connectors/{key}/credential
Set the connector’s credential. Write-once semantics: the payload is validated against the engine’s typed credential model, written through to the secret store, and never echoed — validation errors are sanitized to field names only. Setting a credential clears read_only_verified_at and immediately triggers a probe (and discovery on pass).
# postgres
curl -X PUT http://localhost:8000/api/v1/connectors/operator_prod_db/credential \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"credential": {"username": "aegis_readonly", "password": "..."}}'
# snowflake — key-pair only (PEM PKCS#8 private key; no password auth)
curl -X PUT http://localhost:8000/api/v1/connectors/warehouse/credential \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"credential": {"username": "AEGIS_SVC", "private_key": "-----BEGIN PRIVATE KEY-----\n..."}}'Response (200):
{
"credential": "set",
"status": "active",
"probe": {
"at": "2026-07-18T10:01:12Z",
"ok": true,
"checks": {
"role_attributes": {"ok": true, "detail": "no dangerous role attributes"},
"table_privileges": {"ok": true, "detail": "no write privilege on any relation"},
"schema_create": {"ok": true, "detail": "no CREATE on any schema"},
"database_create": {"ok": true, "detail": "no CREATE on the database"},
"behavioral_create": {"ok": true, "detail": "CREATE TABLE denied"}
},
"error": null,
"remediation": null
}
}Errors: 422 credential_invalid (sanitized field list — the offending value is never included), 409 if the connector is disabled, 503 secret_store_unavailable.
POST /connectors/{key}/probe
Re-run the fail-closed read-only probe. Requires a credential (409 no_credential otherwise).
Response (200): {"status": "...", "probe": {...}} with the same probe shape as above. On failure, probe.error is typed (write_capable, unreachable, resolution_failed, credential_unavailable, probe_error, egress_denied) and probe.remediation contains ready-to-run GRANT/REVOKE SQL for the source DBA.
Postgres checks: transitive role-attribute scan (rolsuper/rolcreatedb/rolcreaterole/rolbypassrls), has_table_privilege write scan, schema/database CREATE scans, rollback-wrapped CREATE TABLE. Snowflake checks (stricter — no session read-only mode exists): transitive SHOW GRANTS walk failing on any write/OWNERSHIP/CREATE* grant or admin-role membership, plus a CREATE TEMPORARY TABLE behavioral check.
POST /connectors/{key}/discover
Re-run schema discovery. Refuses until the probe has passed.
Response (200):
{"schema_count": 1, "table_count": 3, "truncated": false, "truncation_reason": null}Errors: 409 not_verified (probe has not passed), 422 egress_denied, 502 discovery_failed, 503 credential_unavailable. Oversized sources degrade loudly (truncated: true with a truncation_reason) — schema-count cap 50, snapshot byte cap 2 MB.
GET /connectors/{key}/schema
Return the current schema snapshot (404 if discovery has not run yet).
Response (200):
{
"schemas": [
{
"name": "public",
"tables": [
{
"name": "daily_production",
"columns": [
{"name": "well_id", "type": "integer", "nullable": false},
{"name": "prod_date", "type": "date", "nullable": false},
{"name": "oil_bbl", "type": "numeric", "nullable": false}
],
"approx_rows": 2700
}
]
}
],
"truncated": false
}Exposures (R44b)
An exposure is a first-class, connector-scoped, parameterized SELECT query. It is validated structurally (single read statement, no DML/DDL/utility node, no blocklisted function, named :placeholders only) via the shared sqlglot validator, previewed with hard caps, and approved by a hash-pinned admin sign-off. Approve once, bind to many skills (R44c).
Lifecycle: draft → pending_review → approved (or rejected), plus retired. Any edit to content reverts an approved exposure to draft and clears the approval pin — the approval signs the exact sql_text, so laundering an edit past a prior approval is impossible (spec D6).
POST /connectors/validate
The shared validator as a service — powers the authoring editor’s live errors. Structural only (table/column existence is proven by preview, not a stored snapshot). Always returns 200; an invalid draft is a normal UI state.
// request
{"sql_text": "UPDATE wells SET x=1", "dialect": "postgres", "params": []}
// response
{"ok": false, "violations": [{"code": "forbidden_node", "message": "UPDATE is not allowed in an exposure query"}], "param_names": []}Violation codes: empty, parse_error, multiple_statements, not_read_only, forbidden_node, forbidden_function, unnamed_placeholder, numbered_parameter, undeclared_param.
POST /connectors/{key}/exposures
Create a draft. dialect is derived from the connector engine (a mismatched author-supplied dialect → 422 dialect_mismatch). SQL is validated (422 sql_invalid with a violations list). params each declare {name, type: string|number|boolean|date|timestamp, required, default, description}.
{
"exposure_key": "well_flaring",
"display_name": "Well flaring by API",
"description": "Total flared mcf for a well — written for the model (R44c tool description)",
"sql_text": "SELECT api, flared_mcf FROM wells WHERE api = :api",
"params": [{"name": "api", "type": "string", "required": true}],
"row_cap": 200, "byte_cap": 262144, "timeout_s": 10
}The response carries sql_hash (sha256 of the raw exact sql_text), review_status, approved_sql_hash, and is_executable (true only when review_status == "approved" and sql_hash == approved_sql_hash).
POST /connectors/{key}/exposures/{ekey}/approve
Admin-only. Re-validates, then pins approved_sql_hash = sql_hash and records approved_by/approved_at. Optional {"note": "..."}. 409 unless the exposure is pending_review.
POST /connectors/{key}/exposures/{ekey}/preview
Execute a draft/approved exposure against the live source, capped at min(row_cap, 50) rows. Fail-closed: only against a probe-verified connector (active + read_only_verified_at), else 409 not_verified. The first path that runs SQL against a customer DB — it uses the same executor R44c will route agent calls through (validate → coerce params → sqlglot LIMIT-wrap → driver-bound execute → loud row/byte truncation). Params bind driver-native and server-side (postgres $N, snowflake :N numeric); values are never string-interpolated.
// request
{"params": {"api": "42-123-45678"}}
// response
{
"status": "truncated", "columns": ["api", "flared_mcf"],
"rows": [["42-123-45678", 120.5]],
"row_count": 50, "truncated": true, "elapsed_ms": 84,
"row_cap_applied": 50, "preview_note": "preview capped at 50 rows — results truncated"
}Errors: 422 param_validation (missing/mistyped/unknown param), 422 egress_denied, 502 typed engine error (query_timeout/query_error/connect_error), 503 credential_unavailable. Every attempt writes a connector_execution_log row with the params hash only — values are never logged.
POST /connectors/{key}/exposures/{ekey}/execute (service-internal, R44c)
The agent execute path — NotFound at the gateway; orchestration calls it directly with X-Tenant-Id/X-User-Id headers (tenant from the header only, never the body). Runs the exposure’s full row_cap through the same executor as preview, with two additions preview skips: it recomputes sql_hash from the current sql_text and refuses on a mismatch with approved_sql_hash (the T7 DB-tamper drill → 409 hash_mismatch + a connector.exposure_hash_mismatch audit event), and it degrades a disabled/unverified connector to 409 connector_unavailable.
// request (context is optional; used for the log row)
{"params": {"api_number": "42475100001000"},
"context": {"conversation_id": "conv-1", "skill_key": "operator_production_lookup_sf"}}
// response
{
"status": "ok", "columns": ["api_number", "month", "total_gas_mcf"],
"rows": [["42475100001000", "2026-06-01", 41230.5]],
"row_count": 1, "truncated": false, "elapsed_ms": 91,
"sql_hash": "9f2c…"
}Typed errors surface to the LLM as tool errors (never a fabricated empty result, the A5 rule): 409 connector_unavailable, 409 exposure_not_approved / 409 hash_mismatch, 422 param_validation, 422 egress_denied, 502 engine error, 503 credential_unavailable. A connector_execution_log row is written per call (ok/truncated/error/denied) with the params hash only.
GET /connectors/{key}/status · GET /connectors/{key}/bindings (service-internal, R44c)
GET …/status → {connector_key, exists, active, read_only_verified, connector_type} — the registry client’s 30 s-cached check that decides whether a bound tool loads or degrades. GET …/bindings → a list of {exposure_key, display_name, description, params, row_cap, data_classification, is_executable} for the connector’s approved, executable exposures (effective data_classification = the exposure’s own label else the connector floor) — orchestration turns each into an xq_{connector_key}_{exposure_key} tool.
Audit Events
Every mutation lands an HMAC-signed row in the append-only audit_logs table:
| Action | Notes |
|---|---|
connector.created / updated / deleted | Catalog lifecycle |
connector.disabled / enabled | Explicit state changes |
connector.credential_set | SHA-256 digests of the payload and username only — never material |
connector.probe_passed / probe_failed | Every probe verdict |
connector.discovered | Schema/table counts + truncation flag |
connector.egress_denied | Host, resolved IP, and deny reason |
exposure.proposed / edited / deleted | Authoring lifecycle (carries sql_hash; edited flags reverted_from_approved) |
exposure.submitted | draft → pending_review |
exposure.approved | Carries the pinned approved_sql_hash |
exposure.rejected / retired | Review decisions |
Exposure previews and (R44c) executions land in connector_execution_log (not audit_logs) — one row per attempt with the params hash, status (preview/ok/truncated/error/denied), row count, elapsed ms, and typed error_class. Param values are never stored.
Credential material never appears in any API response, database row, log line, or audit entry — the catalog stores a secret-store reference only, and the UI only ever sees set/unset.