Skip to main content

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 stateBehavior
behavry.tenant_id is a UUIDOnly 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 bypassAll 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:

RoleAttributesUsed for
Migration identitysuperuser, owns the tablesalembic upgrade, schema changes, data fixes
Runtime identityNOSUPERUSER NOBYPASSRLS, owns nothingEverything 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:

FieldTypeDefaultDescription
plan_tierstring"trial"Plan level: trial, growth, or enterprise
max_agentsint-1Maximum registered agents (-1 = unlimited)
max_rpm_per_agentint60Rate limit: requests per minute per agent
audit_retention_daysint90Days before audit payloads are eligible for purging
deployment_modestring"saas"Deployment type: saas, hybrid, byoc, or self-hosted
license_keystring?nullLicense key for data plane validation
license_expires_atdatetime?nullLicense expiration timestamp
suspended_atdatetime?nullWhen set, all proxy calls for this tenant are blocked
data_protection_policyJSONB?nullData protection pipeline configuration (4 modes)
blast_radius_configJSONB?nullBlast radius limit overrides
auto_activate_thresholdfloat?nullConfidence threshold for auto-activating policy candidates
auto_activate_enabledbool?falseWhether the Red Team policy loop can auto-activate candidates
data_plane_deployment_idstring?nullPopulated when a data plane registers
data_plane_last_seen_atdatetime?nullLast heartbeat from the data plane
data_plane_regionstring?nullData 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 (not 422, 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:

  1. 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.
  2. TenantConfig -- with trial-plan defaults.
  3. AdminUser -- username set to the provided email, password bcrypt-hashed, bound to the new tenant.
  4. 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"
}
MetricSource
tool_calls_30dCount of audit_events where action = 'TOOL_CALL'
agents_active_30dDistinct agent_id values in audit_events
dlp_hits_30daudit_events with non-empty dlp_findings JSONB array
escalations_30dCount of escalations joined through agents.tenant_id
data_volume_bytes_30dSum 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: true via 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

MethodPathAuthDescription
POST/api/v1/signupNoneSelf-service tenant creation
GET/api/v1/admin/setup-statusNoneCheck if platform is initialized
GET/api/v1/admin/usageAdmin30-day usage metrics for current tenant
POST/api/v1/superadmin/tenantsSuper-adminProvision new tenant
GET/api/v1/superadmin/tenantsSuper-adminList all tenants
GET/api/v1/superadmin/tenants/{id}Super-adminGet tenant detail
PATCH/api/v1/superadmin/tenants/{id}Super-adminUpdate tenant config
POST/api/v1/superadmin/tenants/{id}/suspendSuper-adminSuspend tenant
GET/api/v1/superadmin/tenants/{id}/usageSuper-adminTenant usage metrics

Dashboard Onboarding

When the dashboard detects that no tenant exists (setup-status returns initialized: false), it renders a three-step onboarding flow:

  1. Organization setup -- The user enters their organization name, email, and password. This calls POST /api/v1/signup.
  2. 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.
  3. 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 Conflict for 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.