fran_dev
← VOLVER

APROXIMACIÓN A LA ARQUITECTURA MULTI-TENANT

APROXIMACIÓN A LA ARQUITECTURA MULTI-TENANT

Antes que nada un breve comentario sobre cómo me gusta abordar temas nuevos. Una especie de "metodología" que tengo consiste en imaginar casos de uso o proyectos donde exista una necesidad concreta e intentar resolverla. Personalmente, creo que aprendo más cuando me pongo a construir algo. Si bien la teoría ocupa un lugar importante (adjunto material consultado sobre el final del artículo), me resulta más significativo aprender desde la experiencia ya que la exposición al problema me ayuda a discernir qué es lo importante y qué lo accesorio.

En este caso, me imaginé en la situación de tener que resolver el uso compartido de una base de datos y recursos en una aplicación donde múltiples organizaciones o empresas poseen estructuras de datos similares.

Rápidamente pensé en aplicaciones como Trello o Notion, donde existen workspaces, boards y tareas que deben estar correctamente protegidos y aislados, tanto entre distintas organizaciones como entre usuarios de una misma organización con distintos tipos de roles

Mi intuición me llevó a pensar en que debería haber una base de datos separada para cada organización, pero obviamente implicaría un gasto mucho mayor en términos de infraestructura. Luego pensé en la separación por Schemas (sistema similar que se puede encontrar en PostgresSQL), pero tampoco me convenció del todo.

En este punto me puse a investigar y encontré una serie de artículos breves [1, 2, 3, 4] que abordaban este problema y proponen la utilización de un patrón llamado Shared Database, Shared Schema. Me decidí por explorar e implementar esta solución.


Patrón Shared Database, Shared Schema

Este patrón cuenta con las siguientes características:

  • Una sola base de datos para todos los tenants
  • Aislamiento de datos mediante Row Level Security (RLS)
  • company_id como discriminador de tenant

Pensando en el ejemplo de Notion o Trello simplifiqué un esquema similar para intentar implementarlo. Para este ejemplo suponemos la existencia de usuarios que crean una cuenta en la aplicación y mediante la misma pueden ser dueños (owners) de una o múltiples empresas. A partir de esa premisa, se pueden crear Workspaces → Boards → Tareas con la siguiente estructura jerárquica:

Company (tenant raíz)
    ├── Memberships (user + company + role)
    └── Workspaces
            └── Boards
                    └── Tasks

Por otro lado, cada usuario va a tener una membresía donde quede asociado a un rol y a una empresa. Lo pensé de esta forma para que un mismo usuario pueda estar asociado a múltiples empresas con diferentes roles.

Herencia de permisos: otro detalle importante es que los permisos se heredan hacia abajo. Si un usuario es miembro de una company, puede acceder a los workspaces, boards y tasks de esa company según su rol.


Schema de Base de Datos (DBML)

A continuación dejo todos las tablas y enums que utilicé para diseñar la base de datos. Para este proyecto cree una instancia de Supabase en la nube (self-hosted) y allí desplegué la base de datos. Traté de mantener el diseño lo más minimalista posible para enfocarme en la implementación de los conceptos antes mencionados, pero este tipo de desarrollos puede complejizarse bastante más.

Enums

CREATE TYPE role AS ENUM ('owner', 'admin', 'member');
CREATE TYPE status AS ENUM ('to_do', 'in_progress', 'done');
CREATE TYPE priority AS ENUM ('low', 'medium', 'high');
CREATE TYPE deactivated_by AS ENUM ('parent', 'self');

Tablas

users

CREATE TABLE users (
  user_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  username VARCHAR(50) UNIQUE NOT NULL,
  email VARCHAR(255) UNIQUE NOT NULL,
  is_active BOOLEAN DEFAULT true,
  avatar_url TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

companies

CREATE TABLE companies (
  company_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  name VARCHAR(100) NOT NULL,
  address TEXT,
  phone VARCHAR(20),
  email VARCHAR(255),
  logo_url TEXT,
  is_active BOOLEAN DEFAULT true,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  deleted_at TIMESTAMPTZ
);
-- Nota: companies NO tiene deactivated_by porque es la entidad raíz en este ejemplo

memberships

CREATE TABLE memberships (
  membership_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  user_id UUID NOT NULL REFERENCES users(user_id),
  company_id UUID NOT NULL REFERENCES companies(company_id),
  role role NOT NULL DEFAULT 'member',
  is_active BOOLEAN DEFAULT true,
  deactivated_by deactivated_by,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  deleted_at TIMESTAMPTZ,
  UNIQUE(user_id, company_id)
);

workspaces

CREATE TABLE workspaces (
  workspace_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  company_id UUID NOT NULL REFERENCES companies(company_id),
  name VARCHAR(100) NOT NULL,
  description TEXT,
  is_active BOOLEAN DEFAULT true,
  deactivated_by deactivated_by,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  deleted_at TIMESTAMPTZ
);

boards

CREATE TABLE boards (
  board_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  workspace_id UUID NOT NULL REFERENCES workspaces(workspace_id),
  name VARCHAR(100) NOT NULL,
  description TEXT,
  is_active BOOLEAN DEFAULT true,
  deactivated_by deactivated_by,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  deleted_at TIMESTAMPTZ
);

tasks

CREATE TABLE tasks (
  task_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  board_id UUID NOT NULL REFERENCES boards(board_id),
  title VARCHAR(255) NOT NULL,
  description TEXT,
  status status DEFAULT 'to_do',
  priority priority DEFAULT 'medium',
  is_active BOOLEAN DEFAULT true,
  deactivated_by deactivated_by,
  completed_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  deleted_at TIMESTAMPTZ
);

Helper Functions

Este punto fue fundamental para entender cómo implementar este tipo de arquitectura. Cómo los recursos son compartidos, me resultó conveniente validar en la base de datos la pertenencia a las empresas y los roles de cada usuario. Siguiendo parte de lo planteado en este artículo, definí una serie de funciones en PostgresSQL:

Funciones de verificación de permisos

-- Verificar si el usuario actual es miembro de una company
CREATE OR REPLACE FUNCTION is_company_member(company_id uuid)
RETURNS boolean AS $$
  SELECT EXISTS(
    SELECT 1
    FROM public.memberships
    WHERE memberships.company_id = is_company_member.company_id
    AND memberships.user_id = auth.uid()
  );
$$ LANGUAGE SQL SECURITY DEFINER STABLE SET search_path = public, auth;

-- Verificar si el usuario actual es admin u owner
CREATE OR REPLACE FUNCTION is_company_admin(company_id uuid)
RETURNS boolean AS $$
  SELECT EXISTS(
    SELECT 1
    FROM public.memberships
    WHERE memberships.company_id = is_company_admin.company_id
    AND memberships.user_id = auth.uid()
    AND memberships.role IN ('admin', 'owner')
  );
$$ LANGUAGE SQL SECURITY DEFINER STABLE SET search_path = public, auth;

-- Verificar si el usuario actual es owner
CREATE OR REPLACE FUNCTION is_company_owner(company_id uuid)
RETURNS boolean AS $$
  SELECT EXISTS(
    SELECT 1
    FROM public.memberships
    WHERE memberships.company_id = is_company_owner.company_id
    AND memberships.user_id = auth.uid()
    AND memberships.role = 'owner'
  );
$$ LANGUAGE SQL SECURITY DEFINER STABLE SET search_path = public, auth;

-- Navegar de workspace a company
CREATE OR REPLACE FUNCTION is_workspace_member(ws_id uuid)
RETURNS boolean AS $$
  SELECT public.is_company_member(
    (SELECT company_id FROM public.workspaces WHERE workspace_id = ws_id)
  );
$$ LANGUAGE SQL SECURITY DEFINER STABLE SET search_path = public, auth;

-- Navegar de board a company
CREATE OR REPLACE FUNCTION is_board_member(b_id uuid)
RETURNS boolean AS $$
  SELECT public.is_company_member(
    (SELECT company_id FROM public.workspaces
     WHERE workspace_id = (SELECT workspace_id FROM public.boards WHERE board_id = b_id))
  );
$$ LANGUAGE SQL SECURITY DEFINER STABLE SET search_path = public, auth;

Una serie de aclaraciones importantes:

  • NUNCA aceptar user_id como parámetro (evita enumeración de memberships)
  • Siempre usar auth.uid() internamente
  • SECURITY DEFINER permite que la función bypassee RLS para verificar permisos
  • SET search_path = public, auth es necesario para que encuentre las funciones del schema auth dentro de Supabase.

Row Level Security (RLS)

Como mencioné anteriormente, este tipo de arquitectura requiere, necesariamente, definir un esquema de permisos sólidos para evitar cualquier tipo de filtración de datos entre usuarios que comparten los recursos de la base de datos. A continuación dejo una tabla con la estructura que pensé para este caso:

Matriz de permisos

TablaOperaciónmemberadminowner
companiesSELECT
companiesINSERT✅ (auth.uid)
companiesUPDATE
companiesDELETE
membershipsSELECT
membershipsINSERT
membershipsUPDATE
membershipsDELETE
workspacesSELECT
workspacesINSERT
workspacesUPDATE
workspacesDELETE
boardsSELECT
boardsINSERT
boardsUPDATE
boardsDELETE
tasksSELECT
tasksINSERT
tasksUPDATE
tasksDELETE

A partir de esta matriz, implementé una serie de policies en Supabase. Esto puede realizarse tanto desde la interfaz de Supabase como mediante SQL. En este caso, presento la segunda opción, ya que resulta útil si se quiere replicar la implementación en otros motores de base de datos. Cabe mencionar que estoy utilizando las funciones antes mencionadas para validar cada caso.

Policies implementadas

-- COMPANIES
ALTER TABLE companies ENABLE ROW LEVEL SECURITY;
ALTER TABLE companies FORCE ROW LEVEL SECURITY;

CREATE POLICY "Members can view companies"
  ON companies FOR SELECT
  USING (is_company_member(company_id));

CREATE POLICY "Authenticated users can create companies"
  ON companies FOR INSERT
  WITH CHECK (auth.uid() IS NOT NULL);

CREATE POLICY "Owner can update companies"
  ON companies FOR UPDATE
  USING (is_company_owner(company_id))
  WITH CHECK (is_company_owner(company_id));

CREATE POLICY "Owner can delete companies"
  ON companies FOR DELETE
  USING (is_company_owner(company_id));

-- MEMBERSHIPS
ALTER TABLE memberships ENABLE ROW LEVEL SECURITY;
ALTER TABLE memberships FORCE ROW LEVEL SECURITY;

CREATE POLICY "Members can view memberships"
  ON memberships FOR SELECT
  USING (is_company_member(company_id));

CREATE POLICY "Admins can create memberships"
  ON memberships FOR INSERT
  WITH CHECK (is_company_admin(company_id));

CREATE POLICY "Admins can update memberships"
  ON memberships FOR UPDATE
  USING (is_company_admin(company_id))
  WITH CHECK (is_company_admin(company_id));

CREATE POLICY "Admins can delete memberships"
  ON memberships FOR DELETE
  USING (is_company_admin(company_id));

-- WORKSPACES
ALTER TABLE workspaces ENABLE ROW LEVEL SECURITY;
ALTER TABLE workspaces FORCE ROW LEVEL SECURITY;

CREATE POLICY "Members can view workspaces"
  ON workspaces FOR SELECT
  USING (is_company_member(company_id));

CREATE POLICY "Admins can create workspaces"
  ON workspaces FOR INSERT
  WITH CHECK (is_company_admin(company_id));

CREATE POLICY "Admins can update workspaces"
  ON workspaces FOR UPDATE
  USING (is_company_admin(company_id))
  WITH CHECK (is_company_admin(company_id));

CREATE POLICY "Owners can delete workspaces"
  ON workspaces FOR DELETE
  USING (is_company_owner(company_id));

-- BOARDS
ALTER TABLE boards ENABLE ROW LEVEL SECURITY;
ALTER TABLE boards FORCE ROW LEVEL SECURITY;

CREATE POLICY "Members can view boards"
  ON boards FOR SELECT
  USING (is_workspace_member(workspace_id));

CREATE POLICY "Members can create boards"
  ON boards FOR INSERT
  WITH CHECK (is_workspace_member(workspace_id));

CREATE POLICY "Members can update boards"
  ON boards FOR UPDATE
  USING (is_workspace_member(workspace_id))
  WITH CHECK (is_workspace_member(workspace_id));

CREATE POLICY "Members can delete boards"
  ON boards FOR DELETE
  USING (is_workspace_member(workspace_id));

-- TASKS
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
ALTER TABLE tasks FORCE ROW LEVEL SECURITY;

CREATE POLICY "Members can view tasks"
  ON tasks FOR SELECT
  USING (is_board_member(board_id));

CREATE POLICY "Members can create tasks"
  ON tasks FOR INSERT
  WITH CHECK (is_board_member(board_id));

CREATE POLICY "Members can update tasks"
  ON tasks FOR UPDATE
  USING (is_board_member(board_id))
  WITH CHECK (is_board_member(board_id));

CREATE POLICY "Members can delete tasks"
  ON tasks FOR DELETE
  USING (is_board_member(board_id));

Triggers

Finalmente, tuve la necesidad de crear una serie de funciones y triggers que ayuden al proceso de borrado y activación de cada workspace, board o task. Ya que estamos implementando una estructura jerárquica, los cambios en cada uno de los niveles tiene un impacto en los niveles asociados. Por ejemplo, si elimino un workspace (usé soft delete), los boards y tasks asociados al mismo, deberían eliminarse en cascada de forma automática.

Nota importante: en este modelo se separan explícitamente los conceptos de borrado (soft delete) y desactivación. El borrado se utiliza cuando un elemento debe eliminarse (solo queda el registro para auditorías), mientras que la desactivación permite suspender su funcionamiento. Un caso típico de esto último es el de una membresía impaga, donde resulta conveniente pausar el workspace en lugar de eliminarlo.

Para esto implementé lo siguiente:

Triggers de Soft Delete Cascade (3)

Cuando se borra una company, se propaga automáticamente a sus hijos.

-- Companies → Memberships + Workspaces
CREATE OR REPLACE FUNCTION cascade_soft_delete_companies()
RETURNS TRIGGER AS $$
BEGIN
  UPDATE memberships
  SET deleted_at = NOW(), deactivated_by = 'parent'
  WHERE company_id = NEW.company_id;

  UPDATE workspaces
  SET deleted_at = NOW(), deactivated_by = 'parent'
  WHERE company_id = NEW.company_id;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER soft_delete_company
  AFTER UPDATE OF deleted_at ON companies
  FOR EACH ROW
  WHEN (OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL)
  EXECUTE FUNCTION cascade_soft_delete_companies();

-- Workspaces → Boards
CREATE OR REPLACE FUNCTION cascade_soft_delete_workspaces()
RETURNS TRIGGER AS $$
BEGIN
  UPDATE boards
  SET deleted_at = NOW(), deactivated_by = 'parent'
  WHERE workspace_id = NEW.workspace_id;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER soft_delete_workspace
  AFTER UPDATE OF deleted_at ON workspaces
  FOR EACH ROW
  WHEN (OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL)
  EXECUTE FUNCTION cascade_soft_delete_workspaces();

-- Boards → Tasks
CREATE OR REPLACE FUNCTION cascade_soft_delete_boards()
RETURNS TRIGGER AS $$
BEGIN
  UPDATE tasks
  SET deleted_at = NOW(), deactivated_by = 'parent'
  WHERE board_id = NEW.board_id;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER soft_delete_board
  AFTER UPDATE OF deleted_at ON boards
  FOR EACH ROW
  WHEN (OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL)
  EXECUTE FUNCTION cascade_soft_delete_boards();

Detalle importante: nótese la existencia del campo desactivated_by. Este actúa como un flag que indica si el registro fue eliminado como consecuencia de una acción originada en un elemento de mayor jerarquía.

El objetivo de este enfoque es conservar el estado previo al borrado. De esta manera, en caso de restaurar los datos, es posible distinguir qué elementos fueron eliminados por la acción del padre y cuáles fueron eliminados de forma individual.

Triggers de Restore Cascade (3)

Solo restaura hijos que fueron borrados por el padre (deactivated_by = 'parent').

-- Companies → Memberships + Workspaces
CREATE OR REPLACE FUNCTION cascade_restore_companies()
RETURNS TRIGGER AS $$
BEGIN
  UPDATE memberships
  SET deleted_at = NULL, deactivated_by = NULL
  WHERE company_id = NEW.company_id AND deactivated_by = 'parent';

  UPDATE workspaces
  SET deleted_at = NULL, deactivated_by = NULL
  WHERE company_id = NEW.company_id AND deactivated_by = 'parent';
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER restore_company
  AFTER UPDATE OF deleted_at ON companies
  FOR EACH ROW
  WHEN (OLD.deleted_at IS NOT NULL AND NEW.deleted_at IS NULL)
  EXECUTE FUNCTION cascade_restore_companies();

-- Workspaces → Boards
CREATE OR REPLACE FUNCTION cascade_restore_workspaces()
RETURNS TRIGGER AS $$
BEGIN
  UPDATE boards
  SET deleted_at = NULL, deactivated_by = NULL
  WHERE workspace_id = NEW.workspace_id AND deactivated_by = 'parent';
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER restore_workspaces
  AFTER UPDATE OF deleted_at ON workspaces
  FOR EACH ROW
  WHEN (OLD.deleted_at IS NOT NULL AND NEW.deleted_at IS NULL)
  EXECUTE FUNCTION cascade_restore_workspaces();

-- Boards → Tasks
CREATE OR REPLACE FUNCTION cascade_restore_boards()
RETURNS TRIGGER AS $$
BEGIN
  UPDATE tasks
  SET deleted_at = NULL, deactivated_by = NULL
  WHERE board_id = NEW.board_id AND deactivated_by = 'parent';
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER restore_board
  AFTER UPDATE OF deleted_at ON boards
  FOR EACH ROW
  WHEN (OLD.deleted_at IS NOT NULL AND NEW.deleted_at IS NULL)
  EXECUTE FUNCTION cascade_restore_boards();

Triggers de Pause Cascade (3)

Desactiva recursos sin borrarlos (ejemplo de membresía impaga).

-- Companies → Memberships + Workspaces
CREATE OR REPLACE FUNCTION cascade_pause_company()
RETURNS TRIGGER AS $$
BEGIN
  UPDATE memberships
  SET is_active = false, deactivated_by = 'parent'
  WHERE company_id = NEW.company_id;

  UPDATE workspaces
  SET is_active = false, deactivated_by = 'parent'
  WHERE company_id = NEW.company_id;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER pause_company
  AFTER UPDATE OF is_active ON companies
  FOR EACH ROW
  WHEN (OLD.is_active = true AND NEW.is_active = false)
  EXECUTE FUNCTION cascade_pause_company();

-- Workspaces → Boards
CREATE OR REPLACE FUNCTION cascade_pause_workspaces()
RETURNS TRIGGER AS $$
BEGIN
  UPDATE boards
  SET is_active = false, deactivated_by = 'parent'
  WHERE workspace_id = NEW.workspace_id;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER pause_workspace
  AFTER UPDATE OF is_active ON workspaces
  FOR EACH ROW
  WHEN (OLD.is_active = true AND NEW.is_active = false)
  EXECUTE FUNCTION cascade_pause_workspaces();

-- Boards → Tasks
CREATE OR REPLACE FUNCTION cascade_pause_boards()
RETURNS TRIGGER AS $$
BEGIN
  UPDATE tasks
  SET is_active = false, deactivated_by = 'parent'
  WHERE board_id = NEW.board_id;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER pause_board
  AFTER UPDATE OF is_active ON boards
  FOR EACH ROW
  WHEN (OLD.is_active = true AND NEW.is_active = false)
  EXECUTE FUNCTION cascade_pause_boards();

Triggers de Unpause Cascade (3)

Solo reactiva hijos pausados por el padre.

-- Companies → Memberships + Workspaces
CREATE OR REPLACE FUNCTION cascade_unpause_company()
RETURNS TRIGGER AS $$
BEGIN
  UPDATE memberships
  SET is_active = true, deactivated_by = NULL
  WHERE company_id = NEW.company_id AND deactivated_by = 'parent';

  UPDATE workspaces
  SET is_active = true, deactivated_by = NULL
  WHERE company_id = NEW.company_id AND deactivated_by = 'parent';
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER unpause_company
  AFTER UPDATE OF is_active ON companies
  FOR EACH ROW
  WHEN (OLD.is_active = false AND NEW.is_active = true)
  EXECUTE FUNCTION cascade_unpause_company();

-- Workspaces → Boards
CREATE OR REPLACE FUNCTION cascade_unpause_workspaces()
RETURNS TRIGGER AS $$
BEGIN
  UPDATE boards
  SET is_active = true, deactivated_by = NULL
  WHERE workspace_id = NEW.workspace_id AND deactivated_by = 'parent';
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER unpause_workspace
  AFTER UPDATE OF is_active ON workspaces
  FOR EACH ROW
  WHEN (OLD.is_active = false AND NEW.is_active = true)
  EXECUTE FUNCTION cascade_unpause_workspaces();

-- Boards → Tasks
CREATE OR REPLACE FUNCTION cascade_unpause_boards()
RETURNS TRIGGER AS $$
BEGIN
  UPDATE tasks
  SET is_active = true, deactivated_by = NULL
  WHERE board_id = NEW.board_id AND deactivated_by = 'parent';
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER unpause_board
  AFTER UPDATE OF is_active ON boards
  FOR EACH ROW
  WHEN (OLD.is_active = false AND NEW.is_active = true)
  EXECUTE FUNCTION cascade_unpause_boards();

Trigger de Auto-Owner (1)

Implementé este trigger sencillo para que cuando se crea una company, automáticamente crea un membership con role='owner' asociado al usuario que la creó. Se infiere que es el dueño (no es la única forma de pensarlo).

CREATE OR REPLACE FUNCTION create_owner_membership()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO memberships (user_id, company_id, role, is_active)
  VALUES (auth.uid(), NEW.company_id, 'owner', true);
  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = '';

CREATE TRIGGER on_company_created
  AFTER INSERT ON companies
  FOR EACH ROW
  EXECUTE FUNCTION create_owner_membership();

Conclusión

Implementé este modelo de base de datos en una aplicación de prueba construida con Next.js, para quienes quieran explorar esta arquitectura en un entorno más interactivo y cercano a un caso real.

A lo largo de este proceso, incorporé una nueva forma de pensar y gestionar recursos compartidos, entendiendo mejor cómo las decisiones de arquitectura impactan directamente en la organización, el acceso y el ciclo de vida de los datos.

También reforcé la importancia de incluir validaciones a nivel de base de datos, no solo como una capa adicional de seguridad, sino como un pilar fundamental para garantizar consistencia y previsibilidad en sistemas multi-tenant.

Por último, profundicé en el uso de Row Level Security (RLS) como herramienta clave para controlar el acceso a la información y proteger contenido sensible, especialmente en escenarios donde conviven múltiples actores y niveles de jerarquía.

Espero que parte de este recorrido les resulte interesante o les sirva para construir el suyo.

Recursos consultados

[1] Microsoft Learn: Multitenant SaaS Patterns

[2] How to Design Multitenant SaaS Architecture

[3] Supabase Multi-Tenancy: Simple and Secure

[4] Multi-Tenant Database Architecture Patterns Explained

[5] Supabase Docs: Row Level Security

[6] Enforcing Row Level Security in Supabase

[7] Lock Down Your Data: Implement Row Level Security Policies in Supabase SQL

[8] Building Multi-Tenant SaaS Architectures

▪ ▪ ▪