Building on SOAT
This page is for a fronting service: your own API, holding your own users, calling SOAT on their behalf. Your product's /agents endpoint proxies to SOAT's, and every request has to land inside exactly one customer's data.
The whole pattern is one sentence:
One project per tenant, one project-scoped API key per tenant, and never send a
project_id.
Everything below is why that is enough, and what you get to delete once you adopt it.
The failure mode this replaces
The obvious first implementation is a single admin key, stored once in your environment, used for every tenant. It works on day one, and then it forces a second database on you.
Because that credential can reach every project, SOAT's answer to "which tenant owns agent agent_XYZ?" is no longer trustworthy at your boundary — not because SOAT doesn't know, but because the credential you asked with was allowed to see all of them. So the fronting service starts mirroring: an Agent table holding (soat_id, project_id), then the same for tools, sessions, and providers, then a hand-rolled cascade delete to keep the mirrors from rotting. Each read becomes: look up ownership locally, then call SOAT, then hope the two agree.
Those tables exist to answer a question the platform already answers. Every SOAT resource carries a project_id, and the IAM engine evaluates it on every request — the checks are simply unreachable through an unconfined credential.
What a scoped credential guarantees
Mint the key with project_id set (see Project Scoping) and the binding is a hard boundary, enforced ahead of — and independently of — the owner's role. An admin-owned key is confined exactly like a regular one.
Your call, made with the proj_tenant_a key | What SOAT does |
|---|---|
GET /agents (no project_id) | Returns tenant A's agents. There is no query that widens it |
GET /agents/{id} for a tenant B agent | 404 RESOURCE_NOT_FOUND — existence is not leaked |
POST /agents (no project_id) | Creates in tenant A — the implicit project id |
Any call with project_id=proj_tenant_b | 403 API_KEY_PROJECT_SCOPE, naming both projects |
POST /api-keys for another project, or unscoped | 403 — the credential cannot mint its way out |
That last row is what makes the rest load-bearing. Key creation is self-service, so without it a confined credential could mint an unscoped key for its owning user and be outside its boundary in a single call.
Two consequences worth internalizing:
- Omitting
project_idis the correct call, not a shortcut. The credential names the project, so your proxy handler forwards the client's body as-is. Aproject_idarriving from your own client is something to reject, not to pass through. - A leaked tenant key leaks one tenant. Blast radius is a property of the credential, not of your routing code.
Provisioning a tenant
Three admin-side calls, once per tenant. Do them from your control plane with an admin credential — not with a tenant key, which by design cannot create projects it would then be unable to enter.
- CLI
- SDK
- curl
PROJECT_ID=$(soat create-project --name "tenant-acme" | jq -r '.id')
POLICY_ID=$(soat create-policy \
--name "fronting-layer" \
--document '{
"statement": [
{
"effect": "Allow",
"action": ["agents:*", "tools:*", "sessions:*", "documents:*"],
"resource": ["*"]
}
]
}' | jq -r '.id')
soat create-api-key \
--name "tenant-acme" \
--project_id "$PROJECT_ID" \
--policy_ids "$POLICY_ID"
import { SoatClient } from '@soat/sdk';
const admin = new SoatClient({ baseUrl: SOAT_URL, token: ADMIN_TOKEN });
const { data: project } = await admin.projects.createProject({
body: { name: `tenant-${tenant.slug}` },
});
const { data: key } = await admin.apiKeys.createApiKey({
body: {
name: `tenant-${tenant.slug}`,
project_id: project!.id,
policy_ids: [FRONTING_LAYER_POLICY_ID],
},
});
// key.key is the raw `sk_` secret and is never returned again.
await saveTenantCredential({ tenantId: tenant.id, soatKey: key!.key });
curl -X POST "$SOAT_URL/api/v1/api-keys" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "tenant-acme",
"project_id": "proj_V1StGXR8Z5jdHi6B",
"policy_ids": ["pol_V1StGXR8Z5jdHi6B"]
}'
The resource: ["*"] above is not a hole: the key's project binding already confines it, and the policy's job here is to cap which actions the fronting layer may perform — trim the action list to what your product actually exposes. Store the raw sk_ value in your own secret store; SOAT returns it exactly once.
One policy can be shared by every tenant key. It is the project binding that separates them, so you do not need a policy per tenant.
Serving a request
Your route handler resolves the tenant, loads that tenant's key, and proxies. It does not consult a local ownership table, and it does not add a project_id.
const soatFor = (tenantId: string) => {
return new SoatClient({
baseUrl: SOAT_URL,
token: credentials.get(tenantId), // the tenant's sk_ key
});
};
// GET /api/agents — your product's route
export const listAgents = async (req, res) => {
const { data, error } = await soatFor(req.tenant.id).agents.listAgents({});
if (error) return res.status(error.status ?? 500).json(error);
return res.json(data);
};
// GET /api/agents/:id — no ownership lookup; a foreign id is simply a 404
export const getAgent = async (req, res) => {
const { data, error } = await soatFor(req.tenant.id).agents.getAgent({
path: { agent_id: req.params.id },
});
if (error) return res.status(error.status ?? 500).json(error);
return res.json(data);
};
Cache the client per tenant if you like; it holds no state beyond the base URL and token.
Deprovisioning
Deleting the project is the cascade. force=true removes the project's dependent resources with it, so there is no ordering for you to encode:
soat delete-api-key --api_key_id key_V1StGXR8Z5jdHi6B
soat delete-project --project_id proj_V1StGXR8Z5jdHi6B --force true
Delete the key first if the tenant offboarding can be interrupted: a revoked key stops authenticating immediately, which makes a partially-deleted tenant unreachable rather than half-open.
Which credential does what
| Credential | Held by | Can |
|---|---|---|
| Admin JWT | Your control plane | Create/delete projects, policies, and users; mint keys for any project |
| Unscoped API key | Your control plane, for automation | Anything its owner's policies allow, across projects — not a tenant credential |
| Project-scoped API key | Your request path, one per tenant | Everything inside one project; nothing outside it, including minting its way out |
Keep the first two out of the request path entirely. A request-path credential that can name a project is a request-path credential that can name the wrong project.
What you no longer need
- Ownership mirror tables.
project_idon the SOAT resource is the answer, and the scoped key makes it enforceable rather than advisory. - Ownership checks before each call. A foreign id is a
404from SOAT; a foreignproject_idis a403. - A hand-rolled cascade delete.
delete-project --force truecovers dependents. - Reconciliation jobs. There is only one copy of the ownership fact.
What you keep is the mapping SOAT genuinely cannot know: your tenant id → the SOAT project id and credential. That is one table, one row per tenant.
Operational notes
- Attribution. Requests are attributed to the acting key, so the audit log and traces name which tenant credential acted — not just the owning user.
- Rotation. There is no rotation endpoint: mint a replacement and delete the old key. A tenant key can do this for its own project, so rotation does not need an admin credential in the request path.
- Per-tenant limits. Quotas can be scoped to a project or to an API key, which gives you per-tenant spend caps without any accounting of your own.
- End users inside a tenant. Model your customer's own users as actors, not as more projects. A project is a tenant boundary; an actor is a person inside one.
- User-consented access instead of a stored key. If your integration acts on behalf of a SOAT user who authorizes it — rather than on behalf of your own tenant — use OAuth; its project-scoped tokens are confined by the same boundary.