Skip to main content

Scoped Access

Scoped access lets admins assign specific forms to editors and viewers, restricting their dashboard to only the forms they need.

Overview

Users with global roles editor or viewer don’t automatically see all forms. Instead, an admin explicitly grants them access to individual forms through the Manage Access dialog on the admin settings page (/settings/admins).

How It Works

Two Access Systems

Terra has two independent systems for form-level access:
SystemTablePurposeManaged By
Scoped Accessuser_form_accessAdmin-assigned form grantsSuper admins via Settings
Team Membersprogram_membersPer-form team invitationsForm owners via Form Settings
Both systems are read at login time and merged — the higher role wins per form.

Role Resolution

When determining a user’s effective role for a form, the system:
  1. Queries user_form_access by user_profile_id (admin grants)
  2. Queries program_members by user_id and email (team invitations)
  3. Merges results — higher role wins (owner > editor > viewer)
  4. Applies global role overrides (global viewer caps to read-only)
// lib/form-scope.ts — simplified
const combined = [
  ...programMemberRows,   // legacy team grants
  ...userFormAccessRows,   // admin-managed grants
];

for (const row of combined) {
  const effective = applyGlobalRoleOverride(row.role, globalRole);
  rolesByFormId[row.form_id] = pickHigherRole(rolesByFormId[row.form_id], effective);
}

Global Role Overrides

Global roles set a ceiling on form-level permissions:
Global RoleEffect
super_admin / adminFull access to all forms (bypass scoped access)
editorCan edit assigned forms only
viewerRead-only on assigned forms, even if form role is editor
userStandard form-level permissions apply

Database Schema

user_form_access

The dedicated table for admin-managed grants, introduced in migration 093.
CREATE TABLE user_form_access (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_profile_id UUID NOT NULL REFERENCES user_profiles(id) ON DELETE CASCADE,
  form_id UUID NOT NULL REFERENCES forms(id) ON DELETE CASCADE,
  role TEXT NOT NULL DEFAULT 'viewer',
  granted_by TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(user_profile_id, form_id)
);
Design decisions:
  • References user_profiles(id) — a stable UUID PK — instead of auth.users, avoiding FK issues with WorkOS user IDs
  • Uses TEXT for role instead of an enum, avoiding program_role type compatibility issues across deployments
  • granted_by stores the admin’s user ID for audit purposes

Server Actions

getUserScopedAccess

Returns the list of available forms and the user’s current assignments.
// app/actions/system.ts
const result = await getUserScopedAccess(userId);
// → { forms, workspaces, selectedFormIds }

updateUserScopedAccess

Replaces all form assignments for a user. Uses delete-then-insert for clean state:
// app/actions/system.ts
await updateUserScopedAccess(userId, ["form-id-1", "form-id-2"]);
Internally:
  1. Looks up user_profiles.id by user_id
  2. Deletes all existing user_form_access rows for that profile
  3. Inserts new rows for each selected form

UI

The Manage Access dialog is available on /settings/admins for users with editor or viewer roles. Admins can:
  • See all available forms grouped by workspace
  • Toggle individual forms on/off
  • Save assignments with a single click
Only super_admin and admin users can manage scoped access for others.