Organizing teams with sites and roles

Once you outgrow a single warehouse or depot, raw verifications aren't enough — you need to know which site they belong to, who can act on them, and how to keep one site manager from seeing another site's data. VerifyAI ships a small but opinionated model for this: sites are the unit of physical scoping, member-site assignments wire users to sites, and a four-tier role matrix controls what each user can do.

By the end of this guide you'll know how to:

  1. Create sites and tag verifications with site_id.
  2. Assign members to sites with the right role.
  3. Reason about Row-Level Security (RLS) scoping.
  4. Migrate an existing org-wide setup without breaking anything.

1. The sites schema

Sites live in verify_ai_sites (migration 20260530_verify_ai_sites.sql) and belong to a customer:

| Column | Type | Notes | | ---------------- | ----------- | -------------------------------------------------- | | id | UUID | Primary key. | | customer_id | UUID | FK → billing_customers(id), ON DELETE CASCADE. | | name | TEXT | Human label ("LAX Depot", "Atlanta DC"). | | external_id | TEXT | Stable ID you control. UNIQUE (customer_id, external_id). | | region | TEXT | Optional region tag for grouping. | | timezone | TEXT | IANA tz, defaults to UTC. | | metadata | JSONB | Free-form, defaults to {}. | | archived_at | TIMESTAMPTZ | Soft-delete; archived sites hide from UI. | | created_at | TIMESTAMPTZ | Defaults to now(). |

Member assignments live in verify_ai_member_site_assignments (composite PK on (member_id, site_id), role constrained to site_manager or site_viewer) and join customer_members to verify_ai_sites. The same member can be assigned to many sites; org-level roles (see below) bypass the join entirely and see everything.

Verifications carry an optional site_id column. NULL means org-wide — these rows are visible to org owners / admins but not to site-scoped users. This nullability is what makes the rollout backward-compatible: pre-existing rows stay visible to the people who were already seeing them.

2. The role matrix

There are four roles. Permissions cascade — a role can do everything the role below it can.

| Role | Sees | Can do | | -------------- | --------------------------- | ----------------------------------------------------------------------- | | org_owner | Everything in the org | Members, sites, policies, integrations, audit log, exports, all data. | | org_admin | Everything in the org | Same capability set as org_owner in lib/auth/permissions.ts. | | site_manager | Only their assigned sites | Triage / resolve exceptions and request BI exports within those sites. | | site_viewer | Only their assigned sites | Read-only — view verifications and reports for assigned sites. |

Capabilities are resolved by getMemberCapabilities() (see lib/auth/permissions.ts). The full matrix:

| Capability | org_owner | org_admin | site_manager | site_viewer | | ------------------- | ----------- | ----------- | -------------- | ------------- | | editPolicies | yes | yes | no | no | | manageMembers | yes | yes | no | no | | manageSites | yes | yes | no | no | | manageIntegrations| yes | yes | no | no | | viewAuditLog | yes | yes | no | no | | viewAllSites | yes | yes | no | no | | resolveExceptions | yes | yes | yes | no | | exportBI | yes | yes | yes | no | | viewVerifications | yes | yes | scoped | scoped |

Server actions call this helper before mutating anything, and the dashboard hides controls a member can't exercise. Note that site_manager can resolve exceptions and run BI exports but cannot manage members, sites, or integrations.

3. Tagging a verification with a site

Site resolution happens server-side in lib/verify-ai/service.ts (marked CLUSTER-3-DISPATCH) and reads two fields from the request metadata, in priority order:

  1. metadata.site_id — direct UUID. Wins if both are present. The site must belong to the calling customer; otherwise it's ignored.
  2. metadata.site_external_id — looked up against verify_ai_sites.external_id for the customer.

If neither resolves, the verification is created with site_id = NULL (org-wide). Site resolution is best-effort and never blocks a verification — a missing or unknown site silently falls back to NULL.

bash
# external_id is the easier path — you control the string.
curl -X POST https://verify.switchlabs.dev/api/v1/verify \
  -H "X-API-Key: vai_your_api_key" \
  -F "image=@end_of_ride.jpg" \
  -F "policy=pol_forest1" \
  -F 'metadata={"site_external_id":"LAX-01"}'
ts
// React Native — site_external_id is the recommended path.
await verify({
  image: base64,
  policy: "pol_forest1",
  metadata: {
    site_external_id: "LAX-01",
    trip_id: tripId,
  },
});

4. Scoping (RLS + app layer)

Site-level access control is enforced primarily in application code, not RLS. The dashboard runs as the service role (admin client) and consults getMemberCapabilities() to decide what each member can see. Specifically:

  • verify_ai_sites and verify_ai_member_site_assignments enable RLS with a service-role policy for writes. verify_ai_sites adds a read policy that lets any authenticated member of the customer org see the site list — finer scoping happens in the server actions (see listSites / listExceptions in app/actions/operations.ts, which filter by caps.siteIds when the member lacks viewAllSites).
  • org_owner / org_admincaps.can.viewAllSites === true, so queries skip the site_id filter and see everything in the org.
  • site_manager / site_viewer — queries are restricted to caps.siteIds. Rows with site_id = NULL (legacy org-wide verifications) are not returned to these roles, since the filter is .in('site_id', siteIds) rather than including NULL.

The service role (used by background jobs and logAuditEvent) bypasses RLS as usual.

5. Migrating an existing org

Existing customers have a single implicit "site" — the whole org. To roll out without breaking anything:

  1. Add sites via Dashboard → Operations → Sites or createSite({ name, externalId, region, timezone, metadata }) in app/actions/operations.ts. Only name is required.
  2. Backfill site_id on existing verifications you want scoped (a one-line UPDATE keyed on metadata->>'site_external_id' is the typical move).
  3. Assign members. Until you assign anyone as site_manager / site_viewer, everyone keeps their existing org-level access and nothing changes.

What's next

Get in Touch

Questions about pricing, integrations, or custom deployments? We'd love to hear from you.