A PostgreSQL-only implementation of a multi-tenant task management system, focused on schema design, row-level security, and audit logging.

Multi-Tenant Task Management Database
A PostgreSQL-only implementation of a multi-tenant task management system. The project focuses entirely on schema design, row-level security (RLS), and audit logging — there's no application layer, just the database doing the enforcement work.
About The Project
This is a database-first exploration of multi-tenancy: instead of scoping tenants at the application layer, every table and policy lives in PostgreSQL itself. The core schema — users, teams, team memberships, tasks, and an audit log — is in place and seeded with demo data. The main unfinished work is the write path for non-owner roles and performance tuning for larger datasets.
Key Features
- Multi-tenant schema with
auth.users,core.teams,core.team_members,core.tasks, andaudit.audit_logs - Row-level security enabled across user, team-membership, and task tables
- Forced RLS on
core.tasksso even table owners can't bypass policies - Seeded demo dataset (4 users, 3 teams, 5 memberships, 9 tasks, 1 audit log row)
- Append-only audit logging trigger on task writes
- Dockerized local PostgreSQL 16 setup with schema-only and full data dumps
Development Progress
StatusIn progress — core schema done, write path and performance work remaining
The multi-tenant schema, RLS setup, seeded demo data, and forced owner enforcement on tasks are complete. Task and team-member RLS policies are partially implemented, the audit trigger needs permission and delete-handling fixes, and views, helper query functions, performance indexes, and a large-scale benchmark dataset are not yet implemented.
Technology Stack
Schema Summary
auth.users— user identity and role tablecore.teams— teamscore.team_members— user-to-team membershipcore.tasks— shared tasks scoped by creator, assignee, and teamaudit.audit_logs— append-only audit table for task writesanalytics— schema created in the dump but currently unused
Security Model
The intended access model is user-scoped and team-scoped, enforced entirely through PostgreSQL RLS rather than application logic.
core.taskshasSELECT,INSERT, andUPDATEpolicies, with forced row-level security and noDELETEpolicy yetcore.team_membershas RLS enabled but no policy currently checked inauth.usershas RLS enabled
One concrete inconsistency surfaced during testing: the SELECT and INSERT policies on tasks read a different session variable than the UPDATE policy and the audit trigger, which breaks the write path for non-owner roles until both are set explicitly.
Verified Behavior
Testing as a non-owner role against the seeded dataset showed that task visibility currently behaves more like creator-or-assignee isolation than full team-based isolation, since the team-membership branch of the task policy is effectively blocked by the missing core.team_members policy. Insert and update attempts as a non-owner role surface real, reproducible errors — a missing sequence grant, a missing session variable, and a permissions gap inside the audit trigger — each pointing to a specific fix rather than a vague "doesn't work."
Problems Faced
-
Inconsistent Session Variables : Task policies and the audit trigger don't all read from the same session-setting name, which silently breaks the write path until both variables are set for a session.
-
Sequence and Schema Grants : A non-owner role can have
INSERTprivilege on the tasks table itself while still lacking privileges on the underlying sequence and the audit schema, producing permission errors deeper in the write path than expected. -
Audit Trigger Delete Handling : The audit trigger fires on delete but currently references the wrong row variable, so delete events aren't logged correctly.
-
RLS Performance Under Policy Evaluation : Indexing the base table doesn't remove the cost of evaluating RLS predicates — especially policy subqueries that touch other RLS-protected tables, which can keep queries slow or incomplete even with good indexes on
core.tasksitself.
Key Learnings
-
RLS Is a Cross-Cutting Concern : Row-level security policies, grants, sequences, and triggers all have to agree on the same assumptions — a single inconsistent session-variable name can quietly break an entire write path.
-
Forced RLS Changes the Failure Mode : Using
FORCE ROW LEVEL SECURITYmeans even privileged roles hit policy checks, which surfaces enforcement gaps early instead of letting them hide behind owner bypasses. -
Performance Work Has To Follow Correctness : Benchmarking and indexing only make sense once the write path actually works for the roles being tested — there's little point optimizing queries that don't yet behave correctly.
What's Next
- Fix the write path by aligning grants, trigger permissions, and session-setting names
- Add and verify a
core.team_memberspolicy so team-based visibility actually works - Decide whether the audit trigger should run as
SECURITY DEFINER, and fix delete handling to use the correct row reference - Seed a large (50k+ row) dataset and capture baseline
EXPLAIN ANALYZEoutput - Add only the indexes that demonstrably improve real query plans

