Skip to main content

Portal Access Multi-Company Spec

Purpose

This document replaces the earlier implicit assumption that one portal membership inside one agency organization maps to exactly one client company.

That assumption is no longer valid for the product.

The product must support a single external person being invited into multiple client companies under the same agency organization, while keeping every company grant invite-only and independently revokable.

Examples this must support:

  • a fractional COO working across two client companies run by the same agency
  • a finance contractor with billing access to several subsidiaries under the same agency tenant
  • a client-side manager invited into one company as a manager and another company as a normal customer

Decision

The portal access model is:

  • one portalUser is one org-scoped portal identity
  • one clientContact is one person record inside one client company
  • one portalAccessGrant is one company-specific access grant that links a portal identity to one client company and one person record inside that company

The portal login is no longer the same thing as company access.

The portal login is the identity layer.

Company access is a separate grant layer.

Non-Negotiable Rules

  1. A portal identity is scoped to one agency organization, not to one client company.
  2. A portal identity may hold multiple active company grants inside that same agency organization.
  3. Each company grant must be invite-only.
  4. Each company grant must be independently disabled, suspended, or removed without affecting other active grants for that same portal identity.
  5. Portal permissions are evaluated per company grant, not globally across the full portal identity.
  6. clientContacts remain the canonical people directory for each company.
  7. Agency staff identities and portal identities remain separate realms.

Target Data Model

1. portalUsers becomes the org-scoped identity table

portalUsers should stop carrying company-level access state.

Keep:

  • organizationId
  • userId
  • email
  • name
  • createdAt
  • updatedAt

Remove from the steady-state model:

  • clientAccountId
  • role
  • status

Those fields move to the company-grant layer.

portalUsers should answer:

  • who is this portal person inside this agency organization?
  • what auth user is this identity bound to?

It should not answer:

  • which client companies can they access?
  • what role do they have inside each company?

2. Add portalAccessGrants

Add a new table:

portalAccessGrants

Required fields:

  • organizationId
  • portalUserId
  • userId
  • clientAccountId
  • clientContactId
  • role
    • customer
    • manager
  • status
    • active
    • disabled
    • suspended
  • createdAt
  • updatedAt
  • acceptedAt
  • grantedByUserId
  • grantedByEmail
  • sourceInviteId optional
  • disabledAt optional
  • suspendedAt optional

Required indexes:

  • by_portal_user
  • by_user
  • by_client_account
  • by_client_contact
  • by_organization_portal_user
  • by_organization_client_account
  • by_organization_client_contact
  • by_organization_user
  • by_client_account_status

Recommended uniqueness rule at the application layer:

  • one live grant per organizationId + clientAccountId + clientContactId + portalUserId

This table becomes the source of truth for:

  • whether the person can access a company
  • what role they hold in that company
  • whether that company grant is active, disabled, or suspended

3. portalInvites stays invite-centric, but targets grants

portalInvites should keep its company anchor fields:

  • clientAccountId
  • clientContactId

Add:

  • targetPortalUserId optional

Meaning:

  • before first acceptance, the invite may only know the email and company anchor
  • if the system already knows the org-scoped portal identity for that email, the invite may also point at targetPortalUserId

Invite acceptance must create or update a portalAccessGrant, not overwrite the company pointer on portalUsers.

4. clientContacts stays canonical, but direct portal linking is deprecated

Current field:

  • portalUserId

Final rule:

  • do not use clientContacts.portalUserId as the source of truth for portal access
  • use portalAccessGrants.by_client_contact instead

Migration stance:

  • keep clientContacts.portalUserId only as a temporary migration field during rollout
  • backfill grants from it
  • dual-read during the transition
  • remove or permanently deprecate it after cutover

5. Session context must include selected company grant

Once one portal identity can access multiple companies in the same organization, tenant host alone is no longer enough to resolve the active workspace.

The portal runtime needs two levels of context:

  • agency organization from the tenant host
  • selected client company grant from route, session, or explicit switcher state

The backend session shape should grow from:

  • one active portal membership

to:

  • one portal identity
  • many available company grants
  • one selected company grant for the current request context

6. Portal activity and settings stay identity-scoped unless the feature is explicitly company-scoped

Do not over-correct and make every portal preference company-specific.

Keep these org-scoped to the portal identity unless a product rule demands otherwise:

  • password reset
  • MFA
  • basic notification preferences
  • profile name

Add company context only where the feature is truly company-specific:

  • company grant activity logs
  • company switcher selection memory
  • company-specific support requests if needed later

Invite and Acceptance Rules

First company invite for a new portal person

  1. Agency or authorized client manager invites a clientContact into a specific clientAccount.
  2. Invite acceptance creates the org-scoped auth account if it does not already exist.
  3. Invite acceptance creates portalUsers if it does not already exist.
  4. Invite acceptance creates one active portalAccessGrant for the invited company.

Second company invite for an existing portal person in the same agency org

  1. Invite targets a different clientAccount and clientContact, but the same normalized email.
  2. Acceptance must attach to the existing portalUsers identity.
  3. Acceptance must create a second portalAccessGrant.
  4. Acceptance must not rotate password or invalidate the user’s existing login just because they received another company grant.

Disablement

  • disabling Company A access must disable only the grant for Company A
  • Company B access for that same portal identity must remain active
  • the org-scoped portalUsers identity should only become unusable when it has no remaining active grants or the auth account is explicitly disabled for the full organization

Portal UX Rules

  1. If a portal identity has exactly one active company grant, the portal may auto-open that company workspace.
  2. If a portal identity has multiple active company grants, the shell must present a company switcher.
  3. Every company-scoped page must resolve data through the selected company grant, not by picking an arbitrary canonical portal membership.
  4. Breadcrumbs, page titles, support context, and billing context must all reflect the selected company.
  5. A user may hold manager in one company and customer in another. The UI must evaluate capabilities from the active company grant only.

Product Rules: Who Can Invite Whom

Agency staff

Agency staff with portal.manage_portals may:

  • invite a new external person into any client company they are allowed to manage
  • invite an existing portal identity into an additional client company
  • resend invites
  • disable or suspend company grants
  • upgrade or downgrade company roles
  • grant manager or customer

Agency staff without portal.manage_portals may not:

  • invite portal users
  • change company grant roles
  • disable or suspend company grants

Agency staff must not be invited into the portal realm for the same organization. Internal team access belongs to the org membership model.

Client managers

Client managers are portal identities with an active manager grant for the currently selected company.

Client managers may:

  • invite new or existing external people into that same company only
  • resend invites for that same company only
  • disable or suspend customer grants for that same company only

Client managers may not:

  • invite into a different company unless they also hold manager there
  • grant or upgrade anyone to manager
  • change agency staff access
  • see or manage companies where they do not have an active grant

Agency staff remain the only actors who can grant manager.

Shared contractors

Shared contractors are not a separate auth category.

They are normal portal identities with multiple company grants.

Rules:

  • if they hold customer in a company, they may only use that company workspace
  • if they hold manager in a company, they may perform manager actions for that company only
  • one contractor can simultaneously be:
    • manager for Company A
    • customer for Company B
    • no access at all for Company C

No grant in one company should imply any visibility or authority in another.

Required Backend Changes

Schema and validators

  • packages/backend/convex/schema.ts
    • make portalUsers identity-only
    • add portalAccessGrants
    • update portalInvites
    • treat clientContacts.portalUserId as transitional only

Invite lifecycle

  • packages/backend/convex/portal/access/actions.ts
    • split acceptance into:
      • create identity if missing
      • create grant for invited company
      • do not rotate password for existing org-scoped identity on second-company acceptance
  • packages/backend/convex/portal/access/state.ts
    • create and update invites against grant semantics instead of single-membership semantics
  • packages/backend/convex/portal/access.ts
    • return grant-aware payloads and internal refs

Portal identity and profile

  • packages/backend/convex/portal/profile/operations.ts
    • remove the throw that blocks one portal user from linking to a different clientAccountId
    • stop using portalUsers as the company membership record
    • create or update grants instead
  • packages/backend/convex/portal/shared.ts
    • replace canonical single-membership helpers with identity-plus-grants helpers
    • stop returning a single clientAccountId from assertActivePortalMembership

Session and company context

  • packages/backend/convex/portal/settings/operations.ts
    • session must return available grants and selected grant context
  • packages/backend/convex/portal/session.ts
    • validators and return shapes must become grant-aware

Portal workspace resolution

  • packages/backend/convex/portal/workspace/operations.ts
    • stop inferring one company from one portal membership
    • require an authorized selected company grant
  • packages/backend/convex/portal/workspace.ts
    • validators must include selected company context where needed

Org-side access summary

  • packages/backend/convex/org/clientAccounts.ts
    • build contact access and company summaries from portalAccessGrants, with invites layered on top
    • stop using portalUsers.clientAccountId and clientContacts.portalUserId as steady-state truth

App portal routing and shells

  • apps/app/src/app/[locale]/_lib/convex/api/portal/**
    • session and workspace wrappers must include selected company context
  • apps/app/src/app/[locale]/portal/(dashboard)/**
    • add a company switcher path or selected-company state flow
    • stop assuming one company workspace per authenticated session

Tests and seeds

  • packages/backend/tests/**
    • add cases for second-company invite acceptance and per-company disablement
  • packages/backend/convex/dev/seed/**
    • seed at least one shared contractor with two company grants under one organization

Code Paths That Can Mostly Stay Conceptually Intact

These areas are already close to the target model and should be adapted rather than rewritten from scratch:

  • org-side portal invite entrypoints already target specific clientAccountId and clientContactId
  • org-side People as the canonical company people surface is still correct
  • org-side Portals as the cross-client operational surface is still correct
  • org membership and org staff invite flows remain separate and should not merge into portal logic

Migration Plan

Phase 1. Widen

  1. Add portalAccessGrants.
  2. Add any needed optional fields to portalInvites.
  3. Keep portalUsers.clientAccountId, portalUsers.role, portalUsers.status, and clientContacts.portalUserId temporarily.
  4. Dual-write new accepts and membership updates into both old and new shapes.

Phase 2. Backfill

  1. For every current portalUsers row with a clientAccountId, create a matching portalAccessGrant.
  2. For every clientContacts.portalUserId, reconcile the matching grant by clientContactId.
  3. Verify every active portal company relationship exists as a grant before cutover.

Phase 3. Cut portal reads over to grants

  1. Session code reads identity plus grants.
  2. Portal workspace resolves selected company grant.
  3. Org customer and portal summary code derives access from grants plus invites.

Phase 4. Add multi-company UX

  1. Add company switcher behavior.
  2. Add second-company invite acceptance flow for existing portal identities.
  3. Add per-company disablement and role management.

Phase 5. Narrow

  1. Stop reading portalUsers.clientAccountId, portalUsers.role, and portalUsers.status.
  2. Stop reading clientContacts.portalUserId as access truth.
  3. Remove or permanently deprecate those fields.

Validation Checklist

  • one email can accept two different company invites under the same agency org and keep one login
  • second-company invite acceptance does not reset the existing password
  • company switcher only shows active grants
  • disabling one company grant leaves the other active grants intact
  • client manager invite actions are confined to their active company grant
  • agency can still invite on behalf of any managed company
  • org staff invite flow remains separate from portal invite flow