Data Vault
Query customer data through the Control Plane vault proxy — never call Dataplane directly.
Audience: Federated app developers
Base path: /v1/workspaces/{workspace_id}/vault
Auth: Keycloak bearer token + workspace permissions
Backend: Dataplane (proxied — apps never call Dataplane directly)
Per Lore decision controlplane-data-vault-via-dataplane-dsl-2026-05-03, all customer-data access flows through Control Plane. The API loads a per-workspace DATAPLANE_SERVICE_API_KEY from Infisical and proxies to Dataplane with workspace-scoped tenant context.
Why not Dataplane directly?
| Call Dataplane from browser | Call Control Plane /vault |
|---|---|
| Exposes service API keys | Keys stay server-side |
| Bypasses SpiceDB checks | ReBAC enforced per request |
| No audit trail in Portal | Actions audited (vault.query, vault.record.*, DDL) |
Federated remotes should use the same Control Plane routes as the Customer Portal Data Vault UI.
Permissions
| Operation | Permission |
|---|---|
| List collections, schema, query, count | read |
| Create / update records | write |
| Delete records | delete |
| Create / drop collections (DDL) | ddl (typically workspace admins) |
Endpoints
| Method | Path | Purpose |
|---|---|---|
GET | …/vault/collections | List collections |
GET | …/vault/collections/{name}/schema | JSON Schema + flattened fields |
POST | …/vault/collections/{name}/query | Filtered, paginated query |
GET | …/vault/collections/{name}/count | Document count |
POST | …/vault/collections/{name} | Create record |
PUT | …/vault/collections/{name}/{document_id} | Update record |
DELETE | …/vault/collections/{name}/{document_id} | Delete record |
POST | …/vault/collections | DDL — create collection |
DELETE | …/vault/collections/{name} | DDL — drop collection |
List collections
GET /v1/workspaces/{workspace_id}/vault/collections
Authorization: Bearer {token}{
"collections": [
{
"name": "customers",
"engine": "arangodb",
"document_count": 42,
"description": null
}
]
}engine identifies the underlying connector (arangodb, postgres, clickhouse, qdrant, etc.) — useful for UI icons and query expectations.
Get schema
GET /v1/workspaces/{workspace_id}/vault/collections/customers/schema
Authorization: Bearer {token}{
"name": "customers",
"engine": "arangodb",
"fields": [
{ "name": "name", "type": "string", "required": true, "description": null },
{ "name": "email", "type": "string", "required": false, "description": null }
],
"raw_schema": { "type": "object", "properties": { … }, "required": ["name"] }
}Use fields for tables and forms; use raw_schema when you need full JSON Schema.
Query records
POST /v1/workspaces/{workspace_id}/vault/collections/customers/query
Authorization: Bearer {token}
Content-Type: application/json
{
"filter": { "status": "active" },
"sort": ["-created_at", "name"],
"projection": ["name", "email"],
"limit": 50,
"offset": 0
}Response (QueryResultPage):
{
"items": [ { "_id": "…", "name": "Acme", "status": "active" } ],
"total": 128,
"has_more": true,
"next_offset": 50
}| Field | Default | Max | Notes |
|---|---|---|---|
limit | 50 | 500 | Page size |
offset | 0 | — | Skip N records |
sort | null | — | Field names; prefix - for descending |
projection | null | — | Subset of fields; null = all |
filter | null | — | Engine-aware; see below |
Paginate with offset += len(items) while has_more is true, or use next_offset when present.
Filter expressions
Filters are passed through to Dataplane. The exact operators depend on the collection's engine. Common patterns:
Equality (ArangoDB / default):
{ "filter": { "filter_tag": "my-value" } }Multiple fields (AND semantics — engine-dependent):
{ "filter": { "status": "active", "region": "us-east" } }For advanced DSL (cross-engine portable queries), Dataplane exposes POST /v1/{collection}/dsl/query — not yet wrapped on Control Plane's vault router. Use simple field filters via /vault/.../query for federated apps today.
Dataplane reference docs (sibling repo groundfloor-dataplane-oss):
docs/RBAC_REFERENCE.md— filter operators and auth behaviordocs/REBAC_REFERENCE.md— permission model on Dataplane side- Shell bundled copy:
baas-api/dataplane-api/GROUND_FLOOR_API.md
Count
GET /v1/workspaces/{workspace_id}/vault/collections/customers/count
Authorization: Bearer {token}{ "count": 128 }Filtered count via query param is not exposed at MVP — use query with limit: 1 and read total if you need filtered counts.
Create record
POST /v1/workspaces/{workspace_id}/vault/collections/customers
Authorization: Bearer {token}
Content-Type: application/json
{
"record": {
"name": "Acme Corp",
"email": "ops@acme.example",
"status": "active"
}
}{ "document": { "_id": "customers/abc123", "name": "Acme Corp", … } }Returns 201. Body must conform to the collection JSON Schema — 400 on validation failure.
Update record
PUT /v1/workspaces/{workspace_id}/vault/collections/customers/{document_id}
Authorization: Bearer {token}
Content-Type: application/json
{ "record": { "name": "Acme Corp Updated", "status": "inactive" } }Use the document id from create/query responses (_id, id, or _key depending on engine).
Delete record
DELETE /v1/workspaces/{workspace_id}/vault/collections/customers/{document_id}
Authorization: Bearer {token}Returns 204.
DDL — create collection
Requires ddl permission (workspace administer tier).
POST /v1/workspaces/{workspace_id}/vault/collections
Authorization: Bearer {token}
Content-Type: application/json
{
"schema": {
"name": "my_app_events",
"engine": "arangodb",
"json_schema": {
"type": "object",
"properties": {
"event_type": { "type": "string" },
"payload": { "type": "object" }
},
"required": ["event_type"]
}
}
}Alternative Dataplane-native shape:
{
"schema": {
"name": "my_app_events",
"connection": "arangodb",
"fields": [
{ "name": "event_type", "field_type": "text", "required": true },
{ "name": "payload", "field_type": "json", "required": false }
]
}
}Control Plane translates UI-style json_schema to Dataplane fields via to_dataplane_create_collection.
DDL — drop collection
DELETE /v1/workspaces/{workspace_id}/vault/collections/my_app_events
Authorization: Bearer {token}Returns 204. Destructive — drops all data in the collection.
TypeScript example
import {
configureControlPlaneClient,
setControlPlaneAuthProvider,
useQueryCollectionV1WorkspacesWorkspaceIdVaultCollectionsCollectionQueryPost,
} from "@groundfloor/api-client";
configureControlPlaneClient({ baseURL: process.env.NEXT_PUBLIC_API_URL });
setControlPlaneAuthProvider(async () => keycloak.token ?? null);
function CustomerList({ workspaceId }: { workspaceId: string }) {
const query = useQueryCollectionV1WorkspacesWorkspaceIdVaultCollectionsCollectionQueryPost(
workspaceId,
"customers",
{ filter: { status: "active" }, limit: 25, offset: 0 },
{ query: { enabled: !!workspaceId } },
);
// query.data?.items, query.data?.has_more
}Hook names follow OpenAPI operation ids — regenerate after API changes.
Errors
| Status | Meaning |
|---|---|
401 | Invalid or missing Keycloak token |
403 | Missing workspace permission |
404 | Workspace, collection, or document not found |
503 | Workspace not provisioned, or missing DATAPLANE_SERVICE_API_KEY |
502 | Dataplane rejected request (auth, timeout, upstream error) |
502 auth hint: Workspace needs a provisioned Dataplane service key in Infisical (DATAPLANE_SERVICE_API_KEY, environment ws-{workspace_id prefix}). Re-run workspace provisioning or scripts/backfill_dataplane_workspace_key.py.
503 workspace: Workspace status must be active or frozen; provisioning workspaces return not ready.
Prerequisites (local dev)
- Dataplane running (
groundfloor-dataplane-oss, defaulthttp://localhost:8080) - Workspace provisioning saga completed (mints Dataplane tenant + API key)
- Control Plane
.env:DATAPLANE_URL=http://localhost:8080 - Optional: create test collections via Portal Data Vault UI or DDL API
Related
- 03-authentication.md — Keycloak + permissions
- 05-files.md — binary assets (separate from vault documents)
- 11-local-dev-recipes.md — curl examples