Security
Tanvrit Compute runs untrusted code on behalf of authenticated users across a fleet of agents that may be on hardware you don't fully control. The security model is built around three principles:
- Every actor has its own credential, scoped as narrowly as possible.
- Sensitive material never leaks into job environments.
- Every action is auditable, and nothing is hard-deleted for at least 30
days.
This page documents the model in full.
Credentials
There are three distinct credential types in the system, and they live on different code paths.
Agent API keys
Every tanvrit-agent worker has its own API key, issued at registration time by the control plane. Properties:
- Node-scoped. An agent key can only act on behalf of the node it was
- BCrypt-hashed at rest. The plaintext is shown to the operator exactly
- Rotatable from the portal. Click Rotate key on a node, get a new
- Sent in
X-API-Keyon every agent → control-plane request.
issued for. It cannot submit jobs as a user, list other nodes, or read user data.
once, at creation time. The control plane stores only the hash.
plaintext, paste it into /etc/tanvrit/agent.json, restart the agent. The old hash is invalidated immediately.
User API keys
For programmatic access from the SDK or CLI without an interactive login. Properties:
- Format:
tnv_<prefix>where<prefix>is the first 8 characters of the - User-scoped. All actions are attributed to the owning user.
- Per-key quotas. Each key carries:
- Sent in
X-API-Keyfor SDK requests, or as `Authorization: Bearer
key id, used as a fast lookup index. The full secret is stored as a hash.
- maxJobsPerHour — sliding window enforced by the control plane. - maxConcurrent — number of in-flight jobs the key may have at any time. - allowedJobTypes — subset of COMMAND, DOCKER, GITHUB_ACTION, AI_TOOL. A pytest-only key, for example, can be restricted to DOCKER jobs.
tnv_…` for the CLI.
JWT (interactive)
The portal and the marketing site's /login page issue a short-lived JWT on successful login. JWTs:
- Are bound to a single user account.
- Carry the user's role claims (
user,admin,owner). - Are sent in
Authorization: Bearer …for user-facing endpoints. - Are not accepted on agent-facing endpoints. The agent surface only
trusts X-API-Key.
Webhook signing
Outbound webhooks (job-state notifications, schedule fires, etc.) are signed with HMAC-SHA256 using the per-webhook secret you configured. The signature is delivered in:
X-Tanvrit-Signature: sha256=<hex>
<hex> is the HMAC of the exact request body bytes. To verify in your receiver:
const expected = "sha256=" + crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(rawBody) // raw bytes, NOT a JSON re-serialisation
.digest("hex");
if (!timingSafeEqual(expected, headers["x-tanvrit-signature"])) {
return reject(401);
}
Use a constant-time comparison. Webhooks that don't verify are dropped on the floor.
Sensitive environment variable stripping
When the control plane forwards a job spec to an agent, the following keys are stripped from env regardless of who set them:
TANVRIT_API_KEYAWS_SECRET_ACCESS_KEYGITHUB_TOKEN
(plus any key matching *_SECRET, *_TOKEN, or *_PASSWORD by suffix).
This prevents a user from accidentally — or intentionally — leaking their own control-plane key into a job environment where another job on the same agent could read it through /proc. If you genuinely need to pass a credential to a job, use the dedicated secrets API: secrets are mounted at job start and never persisted to disk on the agent.
Audit log
Every API key use is recorded in an append-only audit log with:
key_id(never the plaintext)actor— user id or node idendpointip_addressuser_agentresult—ok,unauthorized,forbidden,rate_limitedcreated_at
The audit log is queryable from the portal's Audit tab and via:
GET /api/compute/audit?keyId=…&since=24h
A failed-auth burst on a single key id will trigger a Slack alert if you have the alerts integration enabled.
Soft delete & forensic trail
Nothing is hard-deleted in the MVP. When you delete a node, a job, an API key, or a schedule, the row is marked deleted_at = now() and hidden from normal queries. The data remains in the database for 30 days, after which a daily housekeeping job purges it.
This is deliberate: in a security incident you almost always need access to the state of the system before the actor noticed they had been caught and started cleaning up. The 30-day window gives the team time to investigate, extract evidence, and rotate credentials before any rows physically leave the database.
If you need to recover a soft-deleted row inside the window, contact support or use the admin endpoint:
POST /api/admin/restore
{ "table": "jobs", "id": "job_…" }
Hard deletion on demand (for GDPR right-to-erasure requests) is available through the same admin path with ?purge=true.