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_idcomo 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_idcomo parámetro (evita enumeración de memberships) - Siempre usar
auth.uid()internamente SECURITY DEFINERpermite que la función bypassee RLS para verificar permisosSET search_path = public, authes necesario para que encuentre las funciones del schemaauthdentro 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
| Tabla | Operación | member | admin | owner |
|---|---|---|---|---|
| companies | SELECT | ✅ | ✅ | ✅ |
| companies | INSERT | ❌ | ❌ | ✅ (auth.uid) |
| companies | UPDATE | ❌ | ❌ | ✅ |
| companies | DELETE | ❌ | ❌ | ✅ |
| memberships | SELECT | ✅ | ✅ | ✅ |
| memberships | INSERT | ❌ | ✅ | ✅ |
| memberships | UPDATE | ❌ | ✅ | ✅ |
| memberships | DELETE | ❌ | ✅ | ✅ |
| workspaces | SELECT | ✅ | ✅ | ✅ |
| workspaces | INSERT | ❌ | ✅ | ✅ |
| workspaces | UPDATE | ❌ | ✅ | ✅ |
| workspaces | DELETE | ❌ | ❌ | ✅ |
| boards | SELECT | ✅ | ✅ | ✅ |
| boards | INSERT | ✅ | ✅ | ✅ |
| boards | UPDATE | ✅ | ✅ | ✅ |
| boards | DELETE | ✅ | ✅ | ✅ |
| tasks | SELECT | ✅ | ✅ | ✅ |
| tasks | INSERT | ✅ | ✅ | ✅ |
| tasks | UPDATE | ✅ | ✅ | ✅ |
| tasks | DELETE | ✅ | ✅ | ✅ |
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