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, so one tenant's agents, policies, audit events, and configuration are not visible to another tenant even if application code omits a WHERE clause.
That guarantee has a precondition, and it is the single most important thing on this page: the database role your application connects as must not be a superuser and must not hold BYPASSRLS. PostgreSQL skips policy evaluation entirely for such a role. Every policy described below still exists, still looks correct, and does nothing. See The connecting role before relying on any of this.
This model supports both self-hosted single-tenant deployments (where RLS is effectively a no-op) and multi-tenant 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:
"""Restrict this transaction to one tenant's rows."""
if tenant_id is None or tenant_id == "":
await bypass_tenant_isolation(session)
return
# Both variables are written together, always. See the note below.
await session.execute(
text(
"SELECT set_config('behavry.bypass_rls', '', true), "
" set_config('behavry.tenant_id', :tid, true)"
),
{"tid": tenant_id},
)
Both session variables are written on every call, and that is load-bearing rather than tidiness. A single request can legitimately do both: the admin middleware reads across tenants for the pre-authentication admin_users self-lookup, then scopes to whichever tenant that lookup resolved. Both run in one transaction and the variables are transaction-local, so writing only behavry.tenant_id when scoping would leave an earlier bypass in force for the rest of the request.
RLS policies on every tenant-scoped table then enforce row filtering at the database engine level:
CREATE POLICY tenant_isolation ON agents
USING (
coalesce(current_setting('behavry.bypass_rls', true), '') = 'on'
OR coalesce(current_setting('behavry.tenant_id', true), '') = ''
OR tenant_id IS NULL
OR tenant_id::text = current_setting('behavry.tenant_id', true)
);
Isolation semantics
| Session state | Behavior |
|---|---|
behavry.tenant_id is a UUID | Only rows with that tenant_id, or a NULL tenant_id, are visible |
behavry.bypass_rls = 'on' | All rows visible. A deliberate, stated cross-tenant read |
behavry.tenant_id empty and no bypass | All rows visible. See the caveat below |
The last row is a known weakness, documented here rather than glossed over. An empty tenant context means two different things at once: "this caller deliberately reads across tenants" and "nobody set a context on this connection". A code path that simply forgets to set a context therefore gets the same unrestricted view as one that asked for it, so the default fails open.
behavry.bypass_rls exists to separate those two meanings. The legitimate cross-tenant callers are the lookups that run before a tenant is known and so cannot be scoped to one: admin login resolving a username, signup checking whether a slug is taken, the admin self-lookup that decides which tenant the caller belongs to, and agent credential verification. All of them now state their intent explicitly. The empty-context clause remains only until background workers and migrations set an explicit context too, at which point it is removed and a forgotten context stops seeing anything.
Which tables carry policies
138 policies across 76 tenant-scoped tables, verified against pg_policies on a live database rather than by parsing migrations. The count moves as tables are added, so treat the live database as the source of truth:
SELECT count(*) FROM pg_policies WHERE schemaname = 'public';
17 tables carrying a tenant_id column are deliberately exempt and classified in behavry.core.rls_coverage.RLS_EXEMPT: control-plane licensing, billing, and enrollment tables that have no tenant request context. A CI check fails the build if a new tenant-scoped table ships without either a policy or an exemption.
Every one of the 76 also carries FORCE ROW LEVEL SECURITY. Plain ENABLE leaves the table owner exempt from its own policies, and Behavry's migration identity owns these tables, so ENABLE alone would leave policies inert for the very process they exist to constrain.
The connecting role matters most
FORCE closes the table-owner exemption. It does not close the superuser exemption, and nothing in SQL can. If your application connects as a superuser or as a role with BYPASSRLS, PostgreSQL never evaluates a single policy on this page.
Check what you actually connect as:
SELECT current_user, rolsuper, rolbypassrls
FROM pg_roles WHERE rolname = current_user;
Both flags must be f. If either is t, tenant isolation rests entirely on application-level query filtering, and the database is not enforcing anything.
Use two roles, with different jobs:
| Role | Attributes | Used for |
|---|---|---|
| Migration identity | superuser, owns the tables | alembic upgrade, schema changes, data fixes |
| Runtime identity | NOSUPERUSER NOBYPASSRLS, owns nothing | Everything the application does, via BEHAVRY_DB_URL |
The split is not optional in either direction. The runtime role must not bypass, or policies do nothing. The migration role must stay privileged, because under FORCE a data-fixing migration run by a non-superuser owner matches zero rows and reports success rather than failing.
Provisioning the runtime role:
CREATE ROLE behavry_app LOGIN PASSWORD '<generated>'
NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE;
GRANT CONNECT ON DATABASE behavry TO behavry_app;
GRANT USAGE ON SCHEMA public, _timescaledb_internal TO behavry_app;
GRANT SELECT, INSERT, UPDATE, DELETE
ON ALL TABLES IN SCHEMA public, _timescaledb_internal TO behavry_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO behavry_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO behavry_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO behavry_app;
_timescaledb_internal is required because audit_events and the other hypertables keep their chunks in that schema, and a role that cannot read chunks cannot read the hypertable. The ALTER DEFAULT PRIVILEGES lines matter so that tables created by future migrations are reachable without a manual grant each time.
Verify before you cut over, rather than after:
-- Expect zero rows: every table must be reachable.
SELECT c.relname
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind = 'r'
AND NOT (has_table_privilege('behavry_app', c.oid, 'SELECT')
AND has_table_privilege('behavry_app', c.oid, 'INSERT')
AND has_table_privilege('behavry_app', c.oid, 'UPDATE')
AND has_table_privilege('behavry_app', c.oid, 'DELETE'));
You can confirm the behaviour without repointing the application at all. SET ROLE changes the identity RLS evaluates against, so a superuser session can test exactly what the runtime role will see:
BEGIN;
SET ROLE behavry_app;
SELECT set_config('behavry.bypass_rls', '', true),
set_config('behavry.tenant_id', '<tenant-uuid>', true);
SELECT count(*) FROM agents; -- only that tenant's rows
DELETE FROM agents WHERE tenant_id = '<other-tenant-uuid>'; -- expect DELETE 0
ROLLBACK;
Run the same DELETE scoped to the other tenant as a control. If it reports a non-zero count there and zero here, isolation is doing the work rather than the statement being malformed.
Cross-tenant reads
Platform super-admins, and the pre-authentication lookups listed above, read across tenants through the behavry.bypass_rls session variable rather than through a privileged database role. This is deliberate: the grant is transaction-local, so it cannot outlive a request and reach the next caller on a pooled connection, and it works for a runtime role that holds no special database privileges at all.
Database migrations are the exception and do run as the privileged migration identity.
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, so a bug in Python code cannot leak cross-tenant data. This holds only while the application connects as a role that is neither a superuser nor
BYPASSRLS; see The connecting role. Verify it on each deployment rather than assuming it, because the failure is silent: policies stay in place and simply never run. - No tenant enumeration: The signup endpoint returns
409 Conflictfor both duplicate emails and duplicate organization names, preventing attackers from discovering which organizations are registered. - Privileged credential handling: The migration identity and the application-level super-admin user should be protected with strong credentials stored in a secrets manager. Rotate regularly. The migration identity in particular is a superuser and is the one credential whose compromise defeats tenant isolation outright.
- Slug validation: Tenant slugs are restricted to
[a-z0-9-]+to prevent injection.