Skip to main content
Sintesis has a two-layer access control system. The first layer is the membership role (owner, admin, member) attached to every user–tenant relationship. The second layer is a fine-grained permissions system built on top of database-level roles and permission keys. This page covers both layers, plus the invitation flow for adding new users.

Membership roles

Every user who belongs to a tenant has exactly one membership role for that tenant, stored in the memberships table.
CREATE TYPE public.membership_role AS ENUM ('owner', 'admin', 'member');

CREATE TABLE public.memberships (
  tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
  user_id   uuid NOT NULL REFERENCES auth.users(id)  ON DELETE CASCADE,
  role      public.membership_role NOT NULL DEFAULT 'member',
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (tenant_id, user_id)
);
RoleDescription
ownerThe user who created the tenant. Has full access to all features and settings. Exactly one owner per tenant bootstrapped at creation.
adminTrusted users who can manage other users, roles, invitations, and tenant configuration. Identical privilege level to owner for most RLS checks.
memberDefault role for invited users. Access is governed by the fine-grained permissions system described below.
The database helper is_admin_of(tenant) returns true when the current user has owner or admin role in that tenant, and is used throughout RLS policies and the has_permission function.

User management panel

Navigate to Admin → Users to manage members of the active tenant. The page requires the admin:roles permission (automatically satisfied by owner and admin members). The user table shows:
ColumnDescription
UserFull name and email from auth.users metadata
Org roleMembership role (owner, admin, member)
Assigned rolesCustom roles from the user_roles table
OverridesPer-user permission modifications

Inviting users

Click Invite user to open the invitation dialog. Provide the recipient’s email address and choose their initial membership role (member or admin). The system creates an invitation record and sends an email with a unique acceptance link.
Only one pending invitation per email per tenant is allowed at a time. Attempting to invite an email that already has a pending invitation returns an error.

Invitation system

Invitations are stored in the invitations table:
ColumnTypeDescription
iduuidPrimary key
tenant_iduuidThe tenant being invited into
emailtextRecipient email (normalized to lowercase)
invited_byuuidUser ID of the admin who sent the invite
invited_roletextRole to assign on acceptance (member or admin)
statustextpending, accepted, declined, expired, or cancelled
tokentextUnique hex token used in the acceptance URL
expires_attimestamptzAuto-set to 72 hours after creation
accepted_attimestamptzWhen the invite was accepted (nullable)
accepted_byuuidUser ID who accepted (nullable)

Invitation lifecycle

1

Admin sends invitation

An admin calls sendInvitation({ tenantId, email, role }). The server action verifies the caller has is_admin_of(tenantId), creates the invitations row, and sends an email containing the link {APP_URL}/invitations/{token}.
2

Recipient receives email

The recipient opens the link. The page loads invitation details via the get_invitation_by_token(token) RPC function, which returns results only for pending invitations that have not yet expired.
3

Recipient accepts or declines

  • Accept: The server action verifies the recipient’s email matches the invitation, creates a memberships row with the invited role, and marks the invitation as accepted.
  • Decline: The invitation status is updated to declined. No membership is created.
If the user is not logged in when they open the link, they are redirected to the login page with a returnTo parameter so they land back on the invitation after authentication.
4

Invitation expires automatically

The expire_old_invitations() Postgres function sets status = 'expired' on all pending invitations where expires_at <= now(). This function is called at the start of every list query to keep the table tidy.
The invitation is email-locked. If the recipient’s logged-in email does not match invitations.email, the accept action returns an error. Users must log in with the exact email to which the invitation was sent.

Managing pending invitations

Admins can view all pending invitations for a tenant in the Users panel under the pending invitations list. Each row shows the recipient email, the invited role, and the expiry time. Admins can cancel any pending invitation; cancelled invitations cannot be reactivated — a new invitation must be sent.

Fine-grained permissions

Beyond the three membership roles, Sintesis provides a database-driven permissions system that lets admins grant granular access to member-level users.

Core tables

TableDescription
permissionsGlobal registry of permission keys with category, display name, and sort order
rolesTenant-scoped custom role definitions (name, description, color, is_default flag)
role_permissionsMany-to-many join: which permissions a role grants
user_rolesAssignment of roles to users within a tenant
user_permission_overridesPer-user overrides: explicitly grant or deny a specific permission key

Permission keys

Permissions are grouped into categories. The full seeded list is:
KeyDisplay nameDescription
obras:readVer ObrasView obras and their data
obras:editEditar ObrasEdit obra data and tables
obras:adminAdministrar ObrasFull obra management including delete
KeyDisplay nameDescription
certificados:readVer CertificadosView certificates
certificados:editEditar CertificadosCreate and edit certificates
certificados:adminAdministrar CertificadosFull certificate management
KeyDisplay nameDescription
macro:readVer Macro TablasView macro tables (global access)
macro:editEditar Macro TablasEdit macro table data (global access)
macro:adminAdministrar Macro TablasConfigure and manage macro tables
KeyDisplay nameDescription
admin:usersGestionar UsuariosManage tenant users
admin:rolesGestionar RolesManage roles and permissions
admin:auditVer AuditoriaView audit logs
admin:settingsConfiguracionManage tenant settings

How has_permission works

The has_permission(tenant, perm_key) function is called at the application layer to authorize actions. It returns true if any of the following are true:
  1. The calling user is a super-admin (is_superadmin())
  2. The calling user is an owner or admin of the tenant (is_admin_of(tenant))
  3. The user has a user_permission_overrides row granting the permission
  4. The user has a user_roles assignment to a role that has the permission via role_permissions
-- Simplified view of has_permission logic
SELECT
  public.is_superadmin()
  OR public.is_admin_of(tenant)
  OR EXISTS (SELECT 1 FROM user_permission_overrides WHERE user_id = auth.uid() AND is_granted = true AND perm_key = ...)
  OR EXISTS (SELECT 1 FROM user_roles ur JOIN role_permissions rp ON ... WHERE r.tenant_id = tenant AND perm_key = ...)

Macro table permissions

In addition to global permission keys, individual macro tables have their own permission entries in macro_table_permissions. A user or role can be assigned read, edit, or admin access to a specific macro table. These are checked by has_macro_table_permission(macro_table_id, required_level) using the same hierarchy (admin > edit > read).

Role templates

Admins can create roles from pre-built system templates rather than assigning permissions manually:
Template keyNameIncluded permissions
viewerViewerAll nav:* and *:read permissions
editorEditorViewer permissions plus obras:edit, certificados:edit, macro:edit
obra_managerObra ManagerNavigation + full obras access + certificate read
accountantAccountantNavigation + obra read + full certificate access
macro_analystMacro AnalystDashboard + full macro table access

Route access control

Route-level protection is configured in lib/route-access.ts. The file exports ROUTE_ACCESS_CONFIG, an array of path patterns and their required membership roles.
export const ROUTE_ACCESS_CONFIG: RouteAccessConfig[] = [
  // All authenticated users
  { path: "/certificados",    allowedRoles: [] },
  { path: "/excel",           allowedRoles: [] },
  { path: "/macro",           allowedRoles: [] },
  { path: "/notifications",   allowedRoles: [] },

  // Admin/owner only
  { path: "/admin",                allowedRoles: ["admin"] },
  { path: "/admin/audit-log",      allowedRoles: ["admin"] },
  { path: "/admin/tenants",        allowedRoles: ["admin"] },
  { path: "/admin/users",          allowedRoles: ["admin"] },
  { path: "/admin/roles",          allowedRoles: ["admin"] },
  { path: "/admin/tenant-secrets", allowedRoles: ["admin"] },
  // ... and more
];
Rules:
  • Routes not listed in the config are accessible by all authenticated users.
  • Routes listed with allowedRoles: [] are accessible by all authenticated users.
  • Routes listed with allowedRoles: ["admin"] require the user’s membership role to be admin or owner.
  • The "admin" role entry is checked against the membership role, not the custom roles system.
The sidebar also uses this configuration to filter out navigation items the current user cannot access.

Impersonation

Super-admins can impersonate a tenant-level user to debug issues as that user would experience them. When impersonation is active, a yellow banner appears at the top of the Admin → Users page. The banner component checks for an impersonating cookie:
// app/admin/users/_components/impersonate-banner.tsx
const active = document.cookie.includes('impersonating=');
To stop impersonating, click “Volver a mi cuenta” in the banner. This calls POST /api/impersonate/stop, which clears the impersonation cookie and restores the super-admin session.
Impersonation is a super-admin-only feature. It is not available to regular owner or admin users. All actions taken during impersonation are attributed to the impersonated user in the audit log.

Build docs developers (and LLMs) love