Skip to main content
8Space implements a hierarchical multi-tenant architecture where organizations (tenants) contain projects, and users can belong to multiple tenants with different roles.

Tenant Isolation Model

Key Principles

Data Isolation

All project data is scoped to a tenant. Users cannot access projects from tenants they don’t belong to.

Flexible Membership

Users can be members of multiple tenants simultaneously with different roles in each.

URL-Based Routing

Tenant slug in URL provides context: /app/:tenantSlug/projects/:projectId

Hierarchical Permissions

Two-tier system: tenant-level roles and project-level roles

Tenant Schema

Tenant Table

packages/app/supabase/migrations/20260217143000_multi_tenant_onboarding.sql
create table public.tenants (
  id uuid primary key default gen_random_uuid(),
  name text not null check (char_length(name) between 1 and 120),
  slug text not null unique check (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'),
  created_by uuid not null references public.profiles (id) on delete restrict,
  created_at timestamptz not null default now(),
  archived_at timestamptz
);

create index projects_tenant_idx on public.projects (tenant_id);
Slug Requirements:
  • Lowercase alphanumeric with hyphens
  • No leading/trailing hyphens
  • Globally unique
  • URL-safe for routing

Tenant Membership

create type public.tenant_role as enum ('owner', 'admin', 'member');

create table public.tenant_members (
  tenant_id uuid not null references public.tenants (id) on delete cascade,
  user_id uuid not null references public.profiles (id) on delete cascade,
  role public.tenant_role not null,
  joined_at timestamptz not null default now(),
  primary key (tenant_id, user_id)
);

create index tenant_members_user_idx on public.tenant_members (user_id);

Role Hierarchy

Tenant Roles

Permissions:
  • Full control over tenant
  • Manage tenant settings (name, slug)
  • Add/remove members
  • Delete tenant
  • Create projects
Database Check:
select public.is_tenant_owner('tenant-uuid');
Typical Use Case: Founder or organization admin

Project Roles

Permissions:
  • Full project control
  • Manage project members
  • Edit project settings
  • Delete project
  • Create/edit/delete tasks
Auto-Assignment: User who creates the project

Permission Matrix

ActionTenant OwnerTenant AdminTenant MemberProject OwnerProject EditorProject Viewer
View tenant projects
Create project✅*---
Delete tenant---
Manage tenant members---
Edit project settings---
Manage project members---
Create/edit tasks---
View tasks---
Delete project---
* Tenant members can create projects if the tenant policy allows. This is configurable per tenant.

Creating Tenants

Database Function

packages/app/supabase/migrations/20260217143000_multi_tenant_onboarding.sql
create or replace function public.create_tenant_with_owner(
  p_name text,
  p_slug text default null
)
returns public.tenants
language plpgsql
security definer
set search_path = public
as $$
declare
  created public.tenants;
  base_slug text;
  candidate_slug text;
  suffix integer := 2;
begin
  if auth.uid() is null then
    raise exception 'Not authenticated';
  end if;

  if p_name is null or char_length(trim(p_name)) = 0 then
    raise exception 'Tenant name is required';
  end if;

  base_slug := public.normalize_tenant_slug(coalesce(nullif(trim(p_slug), ''), p_name));
  candidate_slug := base_slug;

  loop
    begin
      insert into public.tenants (name, slug, created_by)
      values (trim(p_name), candidate_slug, auth.uid())
      returning * into created;

      exit;
    exception
      when unique_violation then
        candidate_slug := base_slug || '-' || suffix::text;
        suffix := suffix + 1;
    end;
  end loop;

  insert into public.tenant_members (tenant_id, user_id, role)
  values (created.id, auth.uid(), 'owner')
  on conflict (tenant_id, user_id) do update
  set role = 'owner';

  return created;
end;
$$;
Features:
  • Automatic slug generation from name
  • Conflict resolution with numeric suffixes
  • Creator automatically becomes owner
  • Idempotent member insertion

Slug Normalization

create or replace function public.normalize_tenant_slug(p_value text)
returns text
language plpgsql
immutable
as $$
declare
  candidate text;
begin
  candidate := lower(coalesce(trim(p_value), ''));
  candidate := regexp_replace(candidate, '[^a-z0-9]+', '-', 'g');
  candidate := regexp_replace(candidate, '(^-|-$)', '', 'g');

  if candidate = '' then
    return 'space';
  end if;

  return candidate;
end;
$$;
Examples:
normalize_tenant_slug('Acme Corp')        // 'acme-corp'
normalize_tenant_slug('Beta-123!')        // 'beta-123'
normalize_tenant_slug('   Spaces   ')     // 'spaces'
normalize_tenant_slug('!')                // 'space' (fallback)

Creating Projects in Tenants

Updated Function Signature

packages/app/supabase/migrations/20260217143000_multi_tenant_onboarding.sql
create or replace function public.create_project_with_defaults(
  p_tenant_slug text,
  p_name text,
  p_description text
)
returns uuid
The function signature changed from accepting just p_name and p_description to requiring p_tenant_slug as the first parameter.

Auto-Member Addition

insert into public.project_members (project_id, user_id, role)
select
  new_project_id,
  tm.user_id,
  case
    when tm.user_id = auth.uid() then 'owner'::public.project_role
    when tm.role in ('owner', 'admin') then 'editor'::public.project_role
    else 'viewer'::public.project_role
  end
from public.tenant_members tm
where tm.tenant_id = target_tenant_id
on conflict (project_id, user_id) do nothing;
Role Mapping Logic:
1

Project Creator

Always gets owner role regardless of tenant role
2

Tenant Owners/Admins

Automatically get editor role in new projects
3

Tenant Members

Automatically get viewer role in new projects
4

Future Members

When new users join the tenant, they are NOT automatically added to existing projects (must be invited)

Permission Enforcement

Database Functions

create or replace function public.current_tenant_role(p_tenant_id uuid)
returns public.tenant_role
language sql
stable
security definer
set search_path = public
as $$
  select tm.role
  from public.tenant_members tm
  where tm.tenant_id = p_tenant_id
    and tm.user_id = auth.uid();
$$;

create or replace function public.is_tenant_member(p_tenant_id uuid)
returns boolean
as $$
  select public.current_tenant_role(p_tenant_id) is not null;
$$;

create or replace function public.is_tenant_owner(p_tenant_id uuid)
returns boolean
as $$
  select public.current_tenant_role(p_tenant_id) = 'owner';
$$;
The current_project_role function requires BOTH project membership AND tenant membership. This prevents access if a user is removed from the tenant.

Row Level Security

-- Users can only view tenants they belong to
create policy "tenants_select_members"
on public.tenants for select to authenticated
using (public.is_tenant_member(id));

-- Only tenant owners can modify tenant
create policy "tenants_update_owner"
on public.tenants for update to authenticated
using (public.is_tenant_owner(id))
with check (public.is_tenant_owner(id));

-- Only tenant owners can delete
create policy "tenants_delete_owner"
on public.tenants for delete to authenticated
using (public.is_tenant_owner(id));

-- Only tenant owners can manage members
create policy "tenant_members_mutate_owner"
on public.tenant_members for all to authenticated
using (public.is_tenant_owner(tenant_id))
with check (public.is_tenant_owner(tenant_id));

Migration Strategy

From Single-Tenant to Multi-Tenant

The migration 20260217143000_multi_tenant_onboarding.sql automatically converts existing data:
1

Add tenant_id Column

alter table public.projects
  add column tenant_id uuid references public.tenants (id) on delete cascade;
Initially nullable to allow backfill
2

Generate Temporary Mapping

create temporary table project_tenant_map (
  project_id uuid primary key,
  tenant_id uuid not null
) on commit drop;

insert into project_tenant_map (project_id, tenant_id)
select p.id, gen_random_uuid()
from public.projects p;
Each project gets a unique tenant ID
3

Create Tenants from Projects

insert into public.tenants (id, name, slug, created_by, created_at)
select
  map.tenant_id,
  case
    when char_length(trim(p.name)) = 0 then 'Workspace'
    else trim(p.name) || ' Space'
  end,
  'space-' || replace(p.id::text, '-', ''),
  p.created_by,
  p.created_at
from public.projects p
join project_tenant_map map on map.project_id = p.id;
Tenant name derived from project name
4

Backfill tenant_id

update public.projects p
set tenant_id = map.tenant_id
from project_tenant_map map
where map.project_id = p.id;

alter table public.projects
  alter column tenant_id set not null;
Make column required after backfill
5

Migrate Memberships

insert into public.tenant_members (tenant_id, user_id, role, joined_at)
select distinct
  map.tenant_id,
  pm.user_id,
  case
    when pm.role = 'owner' then 'owner'::public.tenant_role
    when pm.role = 'editor' then 'admin'::public.tenant_role
    else 'member'::public.tenant_role
  end,
  pm.joined_at
from public.project_members pm
join project_tenant_map map on map.project_id = pm.project_id;
Convert project roles to tenant roles
6

Drop Single-Tenant Triggers

drop trigger if exists on_profile_created_join_projects on public.profiles;
drop trigger if exists on_project_created_add_members on public.projects;
Remove auto-join behavior from single-tenant mode

URL Structure

Routing Pattern

/app/:tenantSlug/projects/:projectId/tasks/:taskId
Benefits:
  • Clear tenant context in URL
  • Shareable links with implicit workspace
  • SEO-friendly for public pages
  • Simple authorization checks

Repository Interface

packages/app/src/domain/repositories/interfaces.ts
export interface TenantRepository {
  listTenants(userId: string): Promise<Tenant[]>;
  createTenantWithOwner(name: string, preferredSlug?: string): Promise<Tenant>;
}

export interface ProjectRepository {
  listProjects(userId: string, tenantSlug: string): Promise<Project[]>;
  createProjectWithDefaults(
    tenantSlug: string,
    input: CreateProjectInput
  ): Promise<Project>;
  // ...
}
Key Points:
  • All project operations require tenantSlug
  • Tenant context passed explicitly
  • Type-safe tenant isolation

Best Practices

Always Validate Tenant Context

Never trust tenant slug from URL alone. Always verify user membership before data access.
const tenant = await tenantRepo.getTenantBySlug(slug);
if (!tenant || !tenant.isMember) {
  throw new Error('Access denied');
}

Use Slug for Routing, ID for Data

  • URLs: Use human-readable slugs
  • Database: Use UUIDs for foreign keys
  • Convert slug to ID at API boundary

Enforce RLS at Database Level

Never rely on application-level checks alone. Database RLS provides defense-in-depth.

Handle Tenant Switching

Users can be in multiple tenants. Clear client-side caches when switching contexts.

Security Considerations

Risk: User accesses data from wrong tenant via URL manipulationMitigation:
  • RLS policies check tenant membership
  • current_project_role validates both tenant AND project membership
  • Database functions reject cross-tenant operations
Risk: Member promotes themselves to ownerMitigation:
  • RLS policies restrict role changes to owners
  • security definer functions set search_path = public
  • All mutations require explicit permission checks
Risk: Accidental tenant deletion loses all dataMitigation:
  • Soft delete via archived_at timestamp
  • Only owners can archive tenants
  • Cascade deletes handled by foreign keys
  • Consider implementing trash/recovery period

Next Steps

Architecture Overview

Return to high-level architecture concepts

Database Schema

Explore complete table definitions and relationships

Build docs developers (and LLMs) love