Multi-Tenant Architecture
Overview
Behavry isolates tenant data at the database level using PostgreSQL Row-Level Security (RLS). Every query runs in a tenant context set by the admin authentication middleware, ensuring that one tenant's agents, policies, audit events, and configuration are never visible to another tenant -- even if application code omits a WHERE clause.
This model supports both self-hosted single-tenant deployments (where RLS is effectively a no-op) and multi-tenant SaaS deployments with strict data isolation.
PostgreSQL Row-Level Security
How It Works
After JWT validation, the admin middleware calls set_tenant_context() to inject the authenticated tenant's ID into a PostgreSQL session variable:
async def set_tenant_context(session: AsyncSession, tenant_id: str | None) -> None:
value = tenant_id or ""
await session.execute(
text("SELECT set_config('behavry.tenant_id', :tid, true)"),
{"tid": value},
)
RLS policies on every tenant-scoped table then enforce row filtering at the database engine level:
-- Simplified RLS policy (applied in migration d4e5f6a7b8c9)
CREATE POLICY tenant_isolation ON agents
USING (
coalesce(current_setting('behavry.tenant_id', true), '') = ''
OR tenant_id IS NULL
OR tenant_id::text = current_setting('behavry.tenant_id', true)
);
Isolation Semantics
behavry.tenant_id value | Behavior |
|---|---|
UUID string (e.g., a1b2c3...) | Only rows with that tenant_id (or NULL tenant_id) are visible |
| Empty string or unset | All rows visible -- used for super-admin sessions and startup operations |
Tables with RLS Enabled
RLS policies are applied to 10 tenant-scoped tables:
agentspoliciesalertsaudit_eventsadmin_usersenrollment_tokensescalationssessionssiem_destinationspolicy_candidates
Super-Admin Bypass
The behavry_superadmin PostgreSQL role is created with the BYPASSRLS attribute. This role is used for:
- Super-admin sessions (the
set_tenant_context()call passes an empty string) - Background tasks that operate across tenants (retention jobs, heartbeat processing)
- Database migrations
TenantConfig Model
Each tenant has a TenantConfig row that stores plan limits, feature flags, and integration settings:
| Field | Type | Default | Description |
|---|---|---|---|
plan_tier | string | "trial" | Plan level: trial, growth, or enterprise |
max_agents | int | -1 | Maximum registered agents (-1 = unlimited) |
max_rpm_per_agent | int | 60 | Rate limit: requests per minute per agent |
audit_retention_days | int | 90 | Days before audit payloads are eligible for purging |
deployment_mode | string | "saas" | Deployment type: saas, hybrid, byoc, or self-hosted |
license_key | string? | null | License key for data plane validation |
license_expires_at | datetime? | null | License expiration timestamp |
suspended_at | datetime? | null | When set, all proxy calls for this tenant are blocked |
data_protection_policy | JSONB? | null | Data protection pipeline configuration (4 modes) |
blast_radius_config | JSONB? | null | Blast radius limit overrides |
auto_activate_threshold | float? | null | Confidence threshold for auto-activating policy candidates |
auto_activate_enabled | bool? | false | Whether the Red Team policy loop can auto-activate candidates |
data_plane_deployment_id | string? | null | Populated when a data plane registers |
data_plane_last_seen_at | datetime? | null | Last heartbeat from the data plane |
data_plane_region | string? | null | Data plane region label |
SaaS Signup Flow
New tenants self-provision through a single API call that atomically creates all required resources.
Endpoint
POST /api/v1/signup
Request
{
"organization_name": "Acme Corp",
"admin_email": "security@acme.com",
"admin_password": "strong-password-here-12chars"
}
Validation rules:
- Password must be at least 12 characters.
- Organization name must not be empty.
- Duplicate email returns
409 Conflict(not422, to avoid email enumeration). - Duplicate organization name returns
409 Conflict.
Rate Limiting
Signup is rate-limited to 5 requests per IP address per hour using an in-memory sliding-window counter. Exceeding the limit returns 429 Too Many Requests.
What Gets Created
In a single database transaction:
- Tenant -- with an auto-derived URL-safe slug (e.g., "Acme Corp" becomes
acme-corp). Slug uniqueness is enforced; collisions are resolved with a numeric suffix. - TenantConfig -- with trial-plan defaults.
- AdminUser -- username set to the provided email, password bcrypt-hashed, bound to the new tenant.
- EnrollmentToken -- a 24-hour single-use token for the first agent enrollment.
Response
{
"tenant_id": "uuid",
"admin_username": "security@acme.com",
"enrollment_token": "token-urlsafe-32",
"dashboard_url": "/",
"sdk_env_block": "BEHAVRY_CLIENT_ID=<register an agent first>\nBEHAVRY_CLIENT_SECRET=<register an agent first>\nBEHAVRY_ENROLLMENT_TOKEN=token-urlsafe-32"
}
The sdk_env_block is a copy-paste-ready environment variable block for the agent SDK.
Setup Status
The dashboard checks whether the platform has been initialized before showing the login screen:
GET /api/v1/admin/setup-status
Response:
{
"initialized": true
}
This endpoint requires no authentication. It returns true if at least one tenant exists in the database. The dashboard uses this to decide whether to render the onboarding flow or the login page.
Usage Metering
Tenant usage is aggregated from TimescaleDB audit data over a rolling 30-day window. No additional event instrumentation is required -- all metrics are derived from existing tables.
Endpoint
GET /api/v1/admin/usage
Authorization: Bearer <admin_token>
Response
{
"tool_calls_30d": 14832,
"agents_active_30d": 7,
"dlp_hits_30d": 42,
"escalations_30d": 3,
"data_volume_bytes_30d": 285600000,
"period_start": "2026-02-15T00:00:00Z",
"period_end": "2026-03-17T00:00:00Z"
}
| Metric | Source |
|---|---|
tool_calls_30d | Count of audit_events where action = 'TOOL_CALL' |
agents_active_30d | Distinct agent_id values in audit_events |
dlp_hits_30d | audit_events with non-empty dlp_findings JSONB array |
escalations_30d | Count of escalations joined through agents.tenant_id |
data_volume_bytes_30d | Sum of request_size + response_size on audit_events |
Super-Admin API
Platform operators with the is_super_admin flag have access to the super-admin tenant management API. All endpoints are under /api/v1/superadmin/tenants and require super-admin authorization.
Provision a Tenant
curl -X POST https://behavry.example.com/api/v1/superadmin/tenants \
-H "Authorization: Bearer $SUPER_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Acme Corp",
"slug": "acme",
"admin_username": "acme-admin",
"admin_password": "strong-password-here",
"plan_tier": "enterprise",
"max_agents": -1,
"max_rpm_per_agent": 120,
"audit_retention_days": 365,
"deployment_mode": "hybrid"
}'
The response includes an admin_token -- a JWT for the new tenant's admin, ready for programmatic use.
List Tenants
curl https://behavry.example.com/api/v1/superadmin/tenants \
-H "Authorization: Bearer $SUPER_ADMIN_TOKEN"
Returns all tenants ordered by creation date, each with its TenantConfig summary (plan tier, limits, suspension status).
Get Tenant Detail
curl https://behavry.example.com/api/v1/superadmin/tenants/{tenant_id} \
-H "Authorization: Bearer $SUPER_ADMIN_TOKEN"
Update Tenant
curl -X PATCH https://behavry.example.com/api/v1/superadmin/tenants/{tenant_id} \
-H "Authorization: Bearer $SUPER_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"plan_tier": "enterprise",
"max_agents": 100,
"license_key": "lic_new_key",
"license_expires_at": "2027-03-17T00:00:00Z"
}'
Updatable fields: plan_tier, max_agents, max_rpm_per_agent, audit_retention_days, deployment_mode, license_key, license_expires_at, is_active.
Suspend a Tenant
curl -X POST https://behavry.example.com/api/v1/superadmin/tenants/{tenant_id}/suspend \
-H "Authorization: Bearer $SUPER_ADMIN_TOKEN"
Suspension sets is_frozen = true on the tenant and records suspended_at on the TenantConfig. While suspended:
- All proxy calls for the tenant's agents are blocked.
- The tenant admin can still log into the dashboard (read-only visibility into existing data).
- The suspension can be reversed by patching
is_active: truevia the update endpoint.
Tenant Usage
curl https://behavry.example.com/api/v1/superadmin/tenants/{tenant_id}/usage \
-H "Authorization: Bearer $SUPER_ADMIN_TOKEN"
Returns the same UsageSummary structure as the tenant-facing /api/v1/admin/usage endpoint, scoped to the specified tenant.
API Reference Summary
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /api/v1/signup | None | Self-service tenant creation |
GET | /api/v1/admin/setup-status | None | Check if platform is initialized |
GET | /api/v1/admin/usage | Admin | 30-day usage metrics for current tenant |
POST | /api/v1/superadmin/tenants | Super-admin | Provision new tenant |
GET | /api/v1/superadmin/tenants | Super-admin | List all tenants |
GET | /api/v1/superadmin/tenants/{id} | Super-admin | Get tenant detail |
PATCH | /api/v1/superadmin/tenants/{id} | Super-admin | Update tenant config |
POST | /api/v1/superadmin/tenants/{id}/suspend | Super-admin | Suspend tenant |
GET | /api/v1/superadmin/tenants/{id}/usage | Super-admin | Tenant usage metrics |
Dashboard Onboarding
When the dashboard detects that no tenant exists (setup-status returns initialized: false), it renders a three-step onboarding flow:
- Organization setup -- The user enters their organization name, email, and password. This calls
POST /api/v1/signup. - Enroll first agent -- The dashboard displays the enrollment token and a pre-formatted environment variable block for the Behavry SDK. The user copies these into their agent's configuration.
- SSE verification -- The dashboard opens an SSE connection and displays "Waiting for first agent event..." Once an agent registers and sends its first tool call, the message changes to "Connected" and the user proceeds to the main dashboard.
Security Guarantees
- Database-level isolation: RLS policies are enforced by PostgreSQL regardless of application logic. A bug in Python code cannot leak cross-tenant data because the database engine filters rows before they reach the application.
- No tenant enumeration: The signup endpoint returns
409 Conflictfor both duplicate emails and duplicate organization names, preventing attackers from discovering which organizations are registered. - Super-admin credential handling: The
behavry_superadminrole and the application-level super-admin user should be protected with strong credentials stored in a secrets manager. Rotate regularly. - Slug validation: Tenant slugs are restricted to
[a-z0-9-]+to prevent injection.