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, 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 valueBehavior
UUID string (e.g., a1b2c3...)Only rows with that tenant_id (or NULL tenant_id) are visible
Empty string or unsetAll rows visible -- used for super-admin sessions and startup operations

Tables with RLS Enabled

RLS policies are applied to 10 tenant-scoped tables:

  1. agents
  2. policies
  3. alerts
  4. audit_events
  5. admin_users
  6. enrollment_tokens
  7. escalations
  8. sessions
  9. siem_destinations
  10. policy_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:

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. 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 Conflict for both duplicate emails and duplicate organization names, preventing attackers from discovering which organizations are registered.
  • Super-admin credential handling: The behavry_superadmin role 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.