Create your own
Lesson illustration

Defining Tenant Ownership and Access Boundaries

Good to see the product blueprint moving from “what the system does” to “who is allowed to act on what.” The previous lesson established which system owns each business fact: Supabase owns your product workflow and governance records, GoHighLevel owns live CRM and engagement state, and Airtable is an intake source rather than a second CRM.

This lesson adds the security contract around that model. You will specify which organization owns each record, which people may read or change it, which actions are reserved for backend services, and how those boundaries hold when a user belongs to multiple client workspaces. This is the policy your later Supabase migrations, RLS policies, FastAPI authorization dependencies, workers, and dashboard screens will implement.


Ownership is not the same as authorship or permission

For this SaaS, a tenant is a client organization or workspace—not an individual user. A user might be an operator for one B2B client, a viewer for another, and have no relationship at all with a third.

That leads to three distinct concepts that should never be collapsed into one column:

ConceptQuestion it answersExample
Tenant ownershipWhich organization does this business record belong to?A contact belongs to Northstar Manufacturing’s workspace.
Authorship / attributionWho created, approved, or changed it?An operator created a campaign draft; a reviewer approved it.
AuthorizationWhich action may this user perform in the active organization?A reviewer may approve an outreach draft but cannot activate it.

A record’s created_by_user_id is useful for auditability, but it must never determine the tenant boundary. An operator should be able to collaborate on a colleague’s campaign within the same organization, while being completely unable to see a campaign belonging to another organization.

The core invariant for your shared Supabase database is:

Every tenant-owned record belongs to exactly one organization, and a request may act only on records owned by the request’s validated active organization.

This Amazon S3 diagram shows tenants mapped to separate prefixes in a shared bucket, with a coarse bucket-level perimeter and finer access-point policies. Your SaaS needs the same layered idea: tenant identity defines the boundary, while role-specific policies determine the permitted actions within that boundary.

The image is useful as an infrastructure analogy, but a folder prefix such as organizations/<organization_id>/ is not security by itself. Storage paths, database queries, API requests, queue payloads, and integration connections must all carry—and validate—the same organization boundary.

How Senior Engineers Build B2B Multi-Tenant SaaS (RBAC, Billing & Entitlements)

Watch “How Senior Engineers Build B2B Multi-Tenant SaaS (RBAC, Billing & Entitlements)” by Jan Marshal for a concise framing of why B2B data is owned by organizations rather than individual users.

Watch organization isolation. Focus on the distinction between identifying the authenticated person, resolving the organization in which they are acting, and filtering records by organization rather than by user.


The authorization decision: five questions, in order

For every browser request, API request, worker job, webhook, or Claude tool action, the system should answer these questions in this order:

  1. Who is the actor?
    A human user authenticated through Supabase Auth, an internal worker identity, or a verified external webhook source.

  2. Which organization is active?
    A user may select an organization in the UI, but the selection is only a request. The backend must verify that the user has an active membership in that organization.

  3. Does the record belong to that organization?
    The record’s organization_id must match the validated active organization.

  4. Does the actor have the required permission?
    For example, contacts.write, campaigns.approve, or automations.cancel.

  5. Is the action allowed in the record’s current state?
    An operator cannot activate a campaign that is still a draft, and a reviewer cannot approve a version that has been superseded.

This separates the two most important controls:

  • Tenant isolation: Can this organization reach the record at all?
  • Role authorization: What may this person do with a reachable record?

A request header, URL parameter, hidden form field, or frontend state variable containing an organization_id is not proof of tenant access. It is merely an input to validate.

Multi Tenant Security - OWASP Cheat Sheet Series

Read OWASP’s guidance on establishing trusted tenant context. It reinforces the request-handling rule that your FastAPI service must validate the active organization against the authenticated user rather than trusting a client-supplied tenant identifier.

In Section 1, “Tenant Identification & Context Management,” read the five core rules. Pay particular attention to the warning against trusting a tenant ID supplied in a header and the requirement to bind tenant context to an authenticated session.

For your command center, the active-organization resolution policy should be:

  • Next.js sends the user’s Supabase access token to FastAPI.
  • FastAPI validates the token and obtains the authenticated user ID.
  • The UI may request an active organization by ID or slug.
  • FastAPI verifies an active membership for that exact user and organization.
  • FastAPI derives the tenant context from that verified membership.
  • All database queries, connector calls, Claude tool calls, queue jobs, and audit events use that derived context.

Do not put a user’s complete set of roles, permissions, and organization memberships in a long-lived JWT and treat it as permanent truth. Membership and roles can change before a token refreshes. Your database membership record remains authoritative.


Define the organization roles as permission bundles

Your product will later enforce roles and permissions in detail. For now, establish this MVP role policy. The role names are convenient bundles; your backend should ultimately check explicit permissions such as assets.approve, rather than scattering checks like if role == "reviewer" throughout the codebase.

RolePrimary purposeTypical authority
OwnerAccountable tenant administratorManage organization settings, memberships, integrations, data export, offboarding, and all workflow actions.
OperatorRuns day-to-day growth operationsManage prospects and contacts, configure campaigns, create assets, initiate permitted automations, and resolve operational failures.
ReviewerProvides quality and compliance controlRead underlying workflow evidence, comment on outputs, approve or reject eligible outreach and content items.
ViewerObserves performance and statusRead approved business records, dashboards, pipeline summaries, and permitted operational status. Cannot modify or approve records.

Two safeguards should be part of the first production policy:

  1. No self-approval by default.
    An operator who generated an outreach draft or content version may submit it for review but may not approve that same version. An owner may override this only through an explicitly audited action.

  2. Owners are not background services.
    A human owner has broad tenant authority, but a worker, webhook processor, or Claude-powered tool must have only the narrow capabilities required for its job. Internal services do not become omnipotent just because they run without a browser.

The important point for your Claude features is that Claude is not an authorized actor. A Claude tool call must execute under a validated authority chain:

  • the human user who initiated the request, or
  • a specific tenant-scoped automation run created by a permitted user or system rule.

A model request can propose a CRM update or campaign action. Your FastAPI service decides whether the authenticated tenant, user permission, record state, and connector scope permit that action.


Entity-by-entity ownership and access policy

Use the following as the baseline policy for the entities named in the learning outcome. This is intentionally conservative: additional permissions can be added later, but a broadly writable record is difficult to make safe after clients depend on it.

EntityTenant ownership ruleHuman access boundaryService and integration boundary
UsersA user is a global authentication identity and has no organization_id.A user may read and update their own profile. Tenant members see only the limited team-directory information needed by the product.Supabase Auth owns authentication identity. FastAPI never treats a user as a member of an organization without a membership lookup.
Memberships and rolesA membership belongs to one organization and one user. The role belongs to the membership, not the user globally.Owners manage invitations, role changes, and removal. Operators, reviewers, and viewers cannot alter membership or roles.Backend records every role or membership change in the tenant audit log.
ContactsEvery canonical contact has one immutable organization_id. The same email may exist in two client organizations without either client learning about the other.Owners and operators read and edit permitted product-owned contact fields. Reviewers can read evidence relevant to approval. Viewers are read-only.GoHighLevel may contribute CRM delivery, DND, and reply facts only through a validated tenant connection. The browser never writes GoHighLevel IDs or suppression state directly.
OpportunitiesEach opportunity projection belongs to the tenant whose GoHighLevel connection produced it.Owners, operators, reviewers, and viewers may read according to their dashboard needs. Only owners and operators may initiate a CRM handoff or permitted update.GoHighLevel remains authoritative for live stage, value, assignee, and status. Webhooks update the Supabase projection only within the linked organization.
CampaignsA campaign, its targeting rules, business objective, UTM identity, and execution links are tenant-owned.Owners and operators create and edit drafts. Reviewers approve or reject. Viewers see approved or active campaigns and permitted reporting, not editable drafts.Only backend services may activate a CRM execution after checking approval, consent, suppression, frequency, and connector scope.
Content assetsEach tenant-created asset and version belongs to one organization.Owners and operators create drafts and new versions; reviewers comment and approve or reject; viewers see approved or published versions.The asset-generation service records model inputs and outputs under the tenant. It cannot publish an asset merely because it generated one.
Business eventsEvery normalized event has an organization_id, an event source, and immutable observed time.Owners, operators, and reviewers may inspect permitted event timelines. Viewers should receive dashboard-safe event summaries rather than raw payloads containing contact data or provider metadata.Only trusted ingestion services append events. Human users cannot edit or delete event history through the application.
Automation runsA run, each step, and every connector attempt belong to one organization from creation through archival.Owners and operators can initiate permitted runs, inspect errors, and cancel eligible queued work. Reviewers can inspect runs tied to their approval queue. Viewers receive summarized status only.Workers update run status. Every worker job must verify that its run, referenced records, and integration connection share the same organization.

Users: global identity, tenant-local authority

A user is not owned by a tenant. A person can legitimately participate in several client workspaces. The membership creates their tenant-local authority:

user
  global identity: authenticated person

membership
  user_id: authenticated person
  organization_id: client workspace
  role: owner, operator, reviewer, or viewer
  status: invited, active, suspended, removed

This lets one consultant be an owner in their own agency workspace, an operator for Client A, and a viewer for Client B. Every authorization check must evaluate the role in the active organization.

Contacts: tenant-owned despite duplicate real-world identities

A prospect’s domain or a contact’s email can occur across tenants. For example, two of your SaaS customers might both target the same company. That does not create a reason to share a canonical contact record between them.

For v1:

  • Contacts, prospects, research evidence, enrichments, suppressions, and qualification assessments are tenant-local.
  • Deduplication occurs within one organization, never across all customer data.
  • An import may match a contact already owned by the current organization.
  • A cross-tenant match must behave as if it does not exist.

This policy prevents one tenant’s research, sales strategy, contact history, or outreach restrictions from becoming visible to another.

Opportunities: CRM truth with a tenant-scoped projection

An opportunity is visible in the command center because Supabase stores a projection of a GoHighLevel deal. It is still tenant-owned in Supabase: its organization_id is the organization that authorized the connected GoHighLevel location.

An operator may request a handoff or CRM action. But after GoHighLevel confirms a deal exists, your product should not let an operator directly overwrite GoHighLevel-owned stage or value fields in Supabase. The correct workflow is:

  1. The operator makes a permitted request through the command center.
  2. FastAPI validates the organization, permission, state, and connector connection.
  3. The connector calls GoHighLevel.
  4. The returned record or verified webhook updates the tenant-scoped opportunity projection.
  5. Supabase records the request, result, and source timestamps.

Campaigns and assets: draft, approval, and activation are separate authorities

A campaign and an asset must not become one generic “content status” record. They represent different things:

  • A campaign contains the business objective, audience, channel configuration, timing, and measurement identity.
  • An asset contains a versioned email, landing-page copy, social post, or other content artifact.

For either entity, distinguish these actions:

ActionDefault permitted role
Create or revise a draftOwner, operator
View evidence and leave review commentsOwner, operator, reviewer
Approve or reject a submitted versionReviewer, owner
Activate an approved campaignOwner, operator, through backend checks
Publish or send an approved assetBackend connector only, after campaign activation checks
Delete or archiveOwner; operators may archive drafts if you choose to grant it

An approved asset version should remain immutable. If an operator changes the copy after approval, it becomes a new version with a new review state. This preserves the answer to a client’s future question: Exactly what did we approve and send?

Events and automation runs: append-only operational evidence

Events and run records need tighter write boundaries than normal business records.

A business event records something that happened: a contact was created in GoHighLevel, a message was delivered, an opportunity stage changed, or a campaign link was clicked. Treat it as append-only evidence. Corrections should add a compensating or corrected event, not rewrite history.

An automation run records what your platform attempted to do. Its lifecycle may include:

queued, running, waiting_for_review, completed, failed, cancelled, dead_lettered

A user may initiate an allowed automation, but they should not be able to send an API request that marks it completed, attaches a fabricated connector response, or changes the run’s organization_id. Only trusted worker and connector services update those fields.

Raw event payloads, connector errors, prompts, and model responses can contain sensitive data. Store them for troubleshooting and audit needs, but expose only a redacted and role-appropriate view in the client dashboard.


Convert the policy into enforceable data rules

The database model should express ownership directly rather than assuming all child records can infer it through joins.

For every tenant-owned table, establish these baseline rules:

  1. Include a non-null organization_id.
  2. Make organization_id immutable after creation.
  3. Index it, usually alongside the record’s primary lookup fields.
  4. Apply Row Level Security to every table exposed to browser clients.
  5. Use a foreign-key strategy that prevents cross-tenant relationships.
  6. Record created_by_user_id and relevant approval or update attribution separately.
  7. Use server-side services for privileged writes, integration writes, and background updates.

The fifth rule is easy to miss. Consider an automation_runs row that references a campaign_id. Even if both tables have an organization ID, a faulty API could create a run in Organization A that points at Campaign B from Organization B.

Prevent this by enforcing tenant consistency in the database design. A common pattern is to make (organization_id, id) unique on parent tables and have children reference both columns together. Then a child cannot link to a parent from another organization.

Row Level Security | Supabase Docs

Read the relevant sections of Supabase’s Row Level Security documentation. This is the database-level mechanism that will later enforce the tenant policy you are specifying today, even when a browser talks directly to Supabase.

Start in “Understand Row Level Security.” Read the policy mental model, especially the comparison between an RLS policy and an implicit query filter. Then read “Grants and policies,” including the distinction between grants and policies. A role needs permission to perform an operation at all, and then RLS decides which tenant rows that operation can reach. Finally, in “Secure a table with RLS,” read from the discussion beginning locking down a table, then continue into “Write a policy for each operation.” Focus on why SELECT, INSERT, UPDATE, and DELETE require intentionally separate policy decisions.

The conceptual tenant policy for a normal browser-readable table will eventually resemble this:

-- Conceptual policy shape, not a migration to run yet.
using (
  private.is_active_member_of(organization_id)
)
with check (
  private.is_active_member_of(organization_id)
)

For a write operation, the with check portion matters as much as the read filter. It stops a valid user from inserting a row that claims another organization’s ID, or from updating a row to move it across tenant boundaries.

Do not rely only on RLS, however:

  • FastAPI must still validate the authenticated user, active organization, permission, record state, and request schema.
  • RLS must prevent accidental or malicious cross-tenant database access.
  • Connector code must verify that the GoHighLevel location or Airtable connection belongs to the same organization as the record.
  • Workers must validate the tenant scope after dequeuing a job, not trust only the job payload.
  • S3 storage will need both tenant-prefixed paths and policy-controlled access.
  • Audit logs must capture privileged actions, including membership changes, integration changes, exports, approval overrides, and offboarding operations.

One especially important Supabase constraint: a server-side service_role can bypass RLS. That is useful for controlled worker or administration tasks, but it means FastAPI must enforce tenant scope itself before using privileged database access. Never expose a service key in Next.js or to a customer browser.


Define transitions, exceptions, and prohibited paths

A good access policy also specifies what must not happen.

Tenant ownership transitions

For v1, do not support direct transfer of a record from one tenant to another.

If a customer needs a record moved:

  • export it from the source tenant through an owner-authorized process;
  • import it into the destination tenant;
  • create a new tenant-owned record there;
  • preserve an audit record of the export and import;
  • avoid copying hidden operational history, credentials, or suppression decisions by default.

This is safer than permitting an update such as:

organization_id = another_organization_id

Shared resources

Avoid cross-tenant sharing in the initial product model. If you later introduce reusable content templates or agency-managed playbooks, make them a separate platform template or explicitly shared template entity. Do not remove organization_id from a normal asset or campaign merely to make it reusable.

A tenant can copy a platform template into a new tenant-owned draft. The copy is then independently editable, approvable, and auditable.

Integration connection scope

Every connector action must satisfy this check:

record organization
= active organization
= integration connection organization
= external record link organization

For GoHighLevel, that includes the client’s authorized location scope. A valid GoHighLevel contact ID is not sufficient evidence of access; it must be linked through the correct tenant connection and location.

Archive and offboarding behavior

When an organization is suspended, archived, or offboarding:

  • ordinary user writes stop;
  • scheduled campaigns stop before the next send;
  • queued automation runs are cancelled or placed in a terminal blocked state;
  • new connector actions are blocked;
  • privileged export and deletion operations require owner authorization and audit records;
  • retained audit evidence follows the tenant’s documented retention policy.

Produce the tenant-access artifact

Create this file in your repository:

docs/product/tenant-ownership-and-access.md

Use this structure:

# Tenant Ownership and Access Policy

## Core invariants
- Tenant equals organization, not user
- Every tenant-owned entity has one immutable organization ID
- Membership is the source of tenant-local authority
- No cross-tenant record sharing or transfers in v1
- Client-supplied organization IDs are validated, never trusted

## Active organization resolution
- Authenticated actor source
- Requested organization input
- Membership validation
- Suspended or removed membership behavior
- Worker, webhook, and Claude tool authority rules

## Roles and permissions
- Owner
- Operator
- Reviewer
- Viewer
- Self-approval restriction
- Approval override policy

## Entity access matrix
For users, memberships, contacts, opportunities, campaigns,
assets, events, and automation runs:
- Tenant owner
- Read permissions
- Create/update permissions
- Approval or activation permissions
- Service-only writes
- Archive/export rules

## Data integrity requirements
- organization_id requirements
- Cross-tenant foreign-key prevention
- Immutable event and run-history rules
- External connection and record-link scope rules
- Storage-path requirements

## Enforcement layers
- Next.js UI gating
- FastAPI authorization
- Supabase RLS and grants
- Worker validation
- Connector validation
- Audit logging

## Security test scenarios
- Cross-tenant read attempt
- Cross-tenant insert attempt
- Cross-tenant relationship attempt
- Same user, different role in two organizations
- Unauthorized campaign activation
- Automation job with mismatched tenant and connection
- Removed member attempting a cached request

Write the entity access matrix first. It is the heart of the specification. Then add the enforcement and test sections so the policy becomes something engineers can build and verify, not merely a description of roles.


Key takeaways

The tenant boundary for your growth command center is the organization. Users authenticate globally, but all meaningful permissions are evaluated through an active membership in a specific organization.

For the MVP policy:

  • Contacts, opportunities, campaigns, assets, events, and automation runs are tenant-owned records with immutable organization_id values.
  • A user’s authorship of a record does not make that record user-owned.
  • Owners manage tenant administration; operators run workflows; reviewers approve eligible work; viewers observe without changing it.
  • Campaign generation, approval, activation, publication, and delivery are deliberately separate actions.
  • Events and automation history are append-only operational evidence, with trusted services as their writers.
  • FastAPI, RLS, workers, storage policies, and connector scope checks must all enforce the same organization boundary.
  • Claude can recommend or request actions, but it never bypasses the authenticated user’s tenant and permission limits.

Next, you will turn these data and access contracts into an end-to-end architecture for the Next.js frontend, FastAPI services, Claude API features, Supabase, integrations, and AWS deployment components.

Can't find a good explanation? Sign up and we'll make it for you

Sign up