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
portalUseris one org-scoped portal identity - one
clientContactis one person record inside one client company - one
portalAccessGrantis 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
- A portal identity is scoped to one agency organization, not to one client company.
- A portal identity may hold multiple active company grants inside that same agency organization.
- Each company grant must be invite-only.
- Each company grant must be independently disabled, suspended, or removed without affecting other active grants for that same portal identity.
- Portal permissions are evaluated per company grant, not globally across the full portal identity.
clientContactsremain the canonical people directory for each company.- 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:
organizationIduserIdemailnamecreatedAtupdatedAt
Remove from the steady-state model:
clientAccountIdrolestatus
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:
organizationIdportalUserIduserIdclientAccountIdclientContactIdrolecustomermanager
statusactivedisabledsuspended
createdAtupdatedAtacceptedAtgrantedByUserIdgrantedByEmailsourceInviteIdoptionaldisabledAtoptionalsuspendedAtoptional
Required indexes:
by_portal_userby_userby_client_accountby_client_contactby_organization_portal_userby_organization_client_accountby_organization_client_contactby_organization_userby_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:
clientAccountIdclientContactId
Add:
targetPortalUserIdoptional
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.portalUserIdas the source of truth for portal access - use
portalAccessGrants.by_client_contactinstead
Migration stance:
- keep
clientContacts.portalUserIdonly 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
- Agency or authorized client manager invites a
clientContactinto a specificclientAccount. - Invite acceptance creates the org-scoped auth account if it does not already exist.
- Invite acceptance creates
portalUsersif it does not already exist. - Invite acceptance creates one active
portalAccessGrantfor the invited company.
Second company invite for an existing portal person in the same agency org
- Invite targets a different
clientAccountandclientContact, but the same normalized email. - Acceptance must attach to the existing
portalUsersidentity. - Acceptance must create a second
portalAccessGrant. - 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
portalUsersidentity 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
- If a portal identity has exactly one active company grant, the portal may auto-open that company workspace.
- If a portal identity has multiple active company grants, the shell must present a company switcher.
- Every company-scoped page must resolve data through the selected company grant, not by picking an arbitrary canonical portal membership.
- Breadcrumbs, page titles, support context, and billing context must all reflect the selected company.
- A user may hold
managerin one company andcustomerin 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
managerorcustomer
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
customergrants for that same company only
Client managers may not:
- invite into a different company unless they also hold
managerthere - 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
customerin a company, they may only use that company workspace - if they hold
managerin a company, they may perform manager actions for that company only - one contractor can simultaneously be:
managerfor Company Acustomerfor 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
portalUsersidentity-only - add
portalAccessGrants - update
portalInvites - treat
clientContacts.portalUserIdas transitional only
- make
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
- split acceptance into:
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
portalUsersas the company membership record - create or update grants instead
- remove the throw that blocks one portal user from linking to a different
packages/backend/convex/portal/shared.ts- replace canonical single-membership helpers with identity-plus-grants helpers
- stop returning a single
clientAccountIdfromassertActivePortalMembership
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.clientAccountIdandclientContacts.portalUserIdas steady-state truth
- build contact access and company summaries from
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
clientAccountIdandclientContactId - org-side
Peopleas the canonical company people surface is still correct - org-side
Portalsas 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
- Add
portalAccessGrants. - Add any needed optional fields to
portalInvites. - Keep
portalUsers.clientAccountId,portalUsers.role,portalUsers.status, andclientContacts.portalUserIdtemporarily. - Dual-write new accepts and membership updates into both old and new shapes.
Phase 2. Backfill
- For every current
portalUsersrow with aclientAccountId, create a matchingportalAccessGrant. - For every
clientContacts.portalUserId, reconcile the matching grant byclientContactId. - Verify every active portal company relationship exists as a grant before cutover.
Phase 3. Cut portal reads over to grants
- Session code reads identity plus grants.
- Portal workspace resolves selected company grant.
- Org customer and portal summary code derives access from grants plus invites.
Phase 4. Add multi-company UX
- Add company switcher behavior.
- Add second-company invite acceptance flow for existing portal identities.
- Add per-company disablement and role management.
Phase 5. Narrow
- Stop reading
portalUsers.clientAccountId,portalUsers.role, andportalUsers.status. - Stop reading
clientContacts.portalUserIdas access truth. - 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