Tenant isolation, permission model, and workspace management
8Space implements a hierarchical multi-tenant architecture where organizations (tenants) contain projects, and users can belong to multiple tenants with different roles.
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);
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);
create or replace function public.create_tenant_with_owner( p_name text, p_slug text default null)returns public.tenantslanguage plpgsqlsecurity definerset search_path = publicas $$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;$$;
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 endfrom public.tenant_members tmwhere tm.tenant_id = target_tenant_idon 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)
create or replace function public.current_tenant_role(p_tenant_id uuid)returns public.tenant_rolelanguage sqlstablesecurity definerset search_path = publicas $$ 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 booleanas $$ select public.current_tenant_role(p_tenant_id) is not null;$$;create or replace function public.is_tenant_owner(p_tenant_id uuid)returns booleanas $$ 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.
-- Users can only view tenants they belong tocreate policy "tenants_select_members"on public.tenants for select to authenticatedusing (public.is_tenant_member(id));-- Only tenant owners can modify tenantcreate policy "tenants_update_owner"on public.tenants for update to authenticatedusing (public.is_tenant_owner(id))with check (public.is_tenant_owner(id));-- Only tenant owners can deletecreate policy "tenants_delete_owner"on public.tenants for delete to authenticatedusing (public.is_tenant_owner(id));-- Only tenant owners can manage memberscreate policy "tenant_members_mutate_owner"on public.tenant_members for all to authenticatedusing (public.is_tenant_owner(tenant_id))with check (public.is_tenant_owner(tenant_id));
-- Must be tenant member to create projectscreate policy "projects_insert_owner"on public.projects for insert to authenticatedwith check ( created_by = auth.uid() and public.is_tenant_member(tenant_id));-- Can only view projects you're a member ofcreate policy "projects_select_members"on public.projects for select to authenticatedusing (public.is_project_member(id));-- Only project owners can modifycreate policy "projects_update_owner"on public.projects for update to authenticatedusing (public.is_project_owner(id))with check (public.is_project_owner(id));
-- Members can view taskscreate policy "tasks_select_members"on public.tasks for select to authenticatedusing (public.is_project_member(project_id));-- Editors can create taskscreate policy "tasks_insert_editors"on public.tasks for insert to authenticatedwith check (public.can_edit_project(project_id));-- Editors can update taskscreate policy "tasks_update_editors"on public.tasks for update to authenticatedusing (public.can_edit_project(project_id))with check (public.can_edit_project(project_id));-- Editors can delete taskscreate policy "tasks_delete_editors"on public.tasks for delete to authenticatedusing (public.can_edit_project(project_id));
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_atfrom public.projects pjoin project_tenant_map map on map.project_id = p.id;
Tenant name derived from project name
4
Backfill tenant_id
update public.projects pset tenant_id = map.tenant_idfrom project_tenant_map mapwhere 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_atfrom public.project_members pmjoin 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;