Skip to content

Feat/multi tenant - #635

Draft
andrechristikan wants to merge 212 commits into
developmentfrom
feat/multi-tenant
Draft

Feat/multi tenant#635
andrechristikan wants to merge 212 commits into
developmentfrom
feat/multi-tenant

Conversation

@andrechristikan

@andrechristikan andrechristikan commented Mar 12, 2026

Copy link
Copy Markdown
Owner

Multi-Tenant Feature Scope

This document defines the scope for the multi-tenant feature.
Some things are intentionally kept simple for the initial implementation to avoid over-engineering.

Scope note: Everything in this document covers the user role only. Admin and super admin roles are out of scope for this initial implementation.


Out of Scope

The following are explicitly not included in this initial implementation:

  • Admin and super admin endpoints
  • JIT (Just-in-Time) access for platform support
  • Separate tenant login endpoint — standard login is used with lastTenantId in the response
  • Standalone user-owned projects — all projects must belong to a tenant

General

  • Multi-tenant architecture with two levels: tenant and project. One tenant can have many projects.
  • For enabling/disabling tenancy, use a feature flag and let the routes remain registered in code.

Module: Tenant

Setup

  • On sign-up, automatically create a default tenant for the user.
  • Tenant name is randomly generated, similar to how we handle usernames.
  • Tenant slug is auto-generated from the name on creation. Slug is globally unique across all tenants — collision handling applies (random suffix if slug already exists).
  • Track the user's last active tenant via lastTenantId on the User model. This is set on sign-up, on invite acceptance, and on tenant switch — allowing the frontend to redirect the user to their last active tenant on login or page load.
  • Use x-tenant-id header to determine the current tenant context, so users can switch between tenants.

Editable Fields

Only name and description are editable. No status or archive functionality needed.

slug is not updated when name changes. It can only be changed via a dedicated endpoint, similar to how username updates work.

Roles

Tenant roles use an enum directly — no relation to the Role model. Roles are static. One tenant has exactly one owner.

Action Owner Admin Member
View tenant
Update description
Update name
Update slug
Transfer ownership
Manage members (invite, remove, update role)
View member list
Manage projects (CRUD)
View projects
Leave tenant ✅ (with rules)
Delete tenant

Tenant member can only view projects they are assigned to — not all projects in the tenant.

Leave & Delete Rules

  • owner wants to leave → must transfer ownership to another existing member first.
  • owner is the last member → tenant is immediately soft deleted.

Transfer Ownership Rules

  • Target must be an existing tenant member.
  • If the intended new owner is not yet a member, invite them first, wait for acceptance, then transfer.

Soft Delete

  • Uses deletedAt only. No status field.
  • When a tenant is soft deleted, within a single transaction:
    • All projects under the tenant are soft deleted.
    • All pending invitations for the tenant and its projects are revoked.

Module: Project

Setup

  • On sign-up, automatically create a default project under the user's default tenant.
  • Project name is randomly generated.
  • Project slug is auto-generated from the name on creation. Slug is unique per tenant — collision handling applies within the same tenant (random suffix if slug already exists).
  • Every project must belong to a tenant — standalone user-owned projects are not supported.
  • Deletion uses soft delete (deletedAt). No status field needed.

Context

Current project is determined by :projectId route param — no header needed.
The tenant context is already known from x-tenant-id. The guard validates that the project belongs to the current tenant.

x-tenant-id: <tenantId>        ← from header
GET /projects/:projectId        ← projectId from param

Editable Fields

Only name and description are editable.

slug is not updated when name changes. It can only be changed via a dedicated endpoint, similar to how username updates work.

Roles

Project roles are admin, member, viewer. Leave this as a skeleton — each company will have different requirements.

Action Admin Member Viewer
View project
Update project
Update slug
Delete project
Manage members (invite, remove, update role)
View member list
Leave project ✅ (with rules)

Tenant owner and admin can also invite members to any project — enforced at the guard level, no project admin record needed.

Leave Rules

  • Project admin wants to leave → must promote another member to admin first.
  • Project admin is the last member → project is immediately soft deleted.

Member Source & Role Assignment Rules

Only existing tenant members can be invited to a project. There is no direct path from outside the tenant into a project — users must be a tenant member first.

Who Tenant membership Project membership Assignable project role
Tenant owner Already a member Auto-member of all projects admin — hardcoded, cannot be changed
Tenant admin Already a member Auto-member of all projects admin — hardcoded, cannot be changed
Tenant member Already a member Can be invited viewer, member, admin

Tenant owner and admin automatically have project admin access across all projects in their tenant.
This is enforced at the guard level, not in the database.

Revoke Access

Only tenant owner and admin can revoke any project member's access.

Soft Delete

  • Uses deletedAt only. No status field.
  • When a project is soft deleted, within a single transaction:
    • All ProjectMember records are soft deleted.
    • All pending invitations for the project are revoked.

Module: Invitation

Invitations are split into two separate contexts: tenant and project. They are not combined into a single source.

Invite Type

Before creating an invite, the frontend checks whether the email is already registered via POST /check/email. Based on the response, the invite is created with an explicit type:

Type Condition Behavior on claim
registered Email already exists in the system Auto join — no registration needed
unregistered Email not found in the system Redirected to sign-up using the invite token

type is set at invite creation time and does not change afterward.
type only applies to tenant invitation. Project invitation is restricted to existing tenant members only.

Invited users do not get a default tenant and project created — they are joining an existing one.


Tenant Invitation

Who can invite: tenant owner and admin.

Role Rules

  • tenantRole is required and must be admin or member.
  • owner cannot be assigned via invite — ownership can only be transferred through the Transfer Ownership flow.

Model

Must include: invitedById, invitedEmail, tenantId, tenantRole, type, status, expiresAt, revokedAt, revokedById.

Member records are not pre-created before the invite is accepted.

Status Lifecycle

pendingaccepted / expired / revoked

Expiry

Default 7 days, configurable via config. Inviter can override per request via optional expiresIn field.

Duplicate Handling

Determined by exact match on invitedEmail + tenantId:

  • Exact match with a pending invite → replace: existing invite is revoked, new invite is created.

Revoke

Tenant owner or admin can revoke a pending invite at any time. Once revoked, the token is immediately invalidated.

Notification

Invitee receives an email with an invitation token regardless of registration status.

  • registered → claim with token only. Auto joined to the tenant.
  • unregistered → directed to sign-up using the token. Upon registration, auto joined to the tenant.

On Completed

Set lastTenantId for the user immediately after joining.


Project Invitation

Who can invite: tenant owner, tenant admin (via guard), and project admin.

Project invitation is for existing tenant members only. Users who are not yet part of the tenant must be invited via tenant invitation first.

Role Rules

projectRole is required: admin, member, or viewer.

Model

Must include: invitedById, invitedEmail, projectId, projectRole, status, expiresAt, revokedAt, revokedById.

Member records are not pre-created before the invite is accepted.

Status Lifecycle

pendingaccepted / expired / revoked

Expiry

Default 7 days, configurable via config. Inviter can override per request via optional expiresIn field.

Duplicate Handling

Determined by exact match on invitedEmail + projectId:

  • Exact match with a pending invite → replace: existing invite is revoked, new invite is created.

Revoke

Tenant owner, tenant admin, or project admin can revoke a pending invite at any time. Once revoked, the token is immediately invalidated.

Notification

Invitee receives an email with an invitation token. On claim, they are auto joined to the project with the assigned role.

On Completed

No change to lastTenantId — user is already a tenant member.


Module: Auth (Login Response)

On login, include a tenant object in the response so the frontend can immediately set the correct tenant context without an extra request.

{
  accessToken: string,
  refreshToken: string,
  tenant: {
    id: string   // from lastTenantId — always set, default tenant is always created on sign-up
  }
}

Config

The following configs are required under auth.config.ts:

  • tenant.header — header key for resolving tenant context (x-tenant-id)
  • tenant.invite.defaultExpiresIn — default expiry for invite tokens, can be overridden per request

Flows

1. Sign-Up Flow

flowchart TD
    A[User Sign Up] --> B[Create User]
    B --> C[Create default Tenant\nrandom name + slug]
    C --> D[Add user as tenant owner]
    D --> E[Create default Project\nrandom name + slug]
    E --> F[Add user as project admin]
    F --> G[Set lastTenantId on User]
    G --> H[Return login response\nwith tenant object]
Loading

2. Login Flow

flowchart TD
    A[User Login] --> B[Validate credentials]
    B --> C[Return access token\n+ tenant object from lastTenantId]
    C --> D[Frontend sets x-tenant-id\nfrom tenant.id]
    D --> E[User lands on last active tenant]
Loading

3. Tenant Invitation Flow

flowchart TD
    A[Owner or Admin\nwants to invite] --> B[Check email via POST /check/email]
    B --> C{Email registered?}
    C -- Yes --> D[Create invite\ntype: registered]
    C -- No --> E[Create invite\ntype: unregistered]
    D --> F[Send email with token]
    E --> F
    F --> G[Invitee clicks email link]
    G --> H{Invite type?}
    H -- registered --> I[Claim with token only\nAuto join tenant]
    H -- unregistered --> J[Sign up with token\n+ password + signUpFrom]
    J --> K[Create User\nAuto join tenant]
    I --> L[Set lastTenantId\nMark invite as accepted]
    K --> L
    L --> M[Done]
Loading

4. Project Invitation Flow

flowchart TD
    A[Owner, Admin, or Project Admin\nwants to invite] --> B[Select existing tenant member]
    B --> C[Create project invite\nwith projectRole]
    C --> D[Send email with token]
    D --> E[Invitee clicks email link]
    E --> F[Claim with token only\nAuto join project with assigned role]
    F --> G[Mark invite as accepted]
    G --> H[Done]
Loading

5. Revoke Tenant Invite Flow

flowchart TD
    A[Owner or Admin\ncalls revoke endpoint] --> B{Invite status is pending?}
    B -- No --> C[Return error\nalready accepted/expired/revoked]
    B -- Yes --> D[Set status to revoked\nSet revokedAt + revokedById]
    D --> E[Token immediately invalidated]
    E --> F[Done]
Loading

6. Revoke Project Invite Flow

flowchart TD
    A[Owner, Admin, or Project Admin\ncalls revoke endpoint] --> B{Invite status is pending?}
    B -- No --> C[Return error\nalready accepted/expired/revoked]
    B -- Yes --> D[Set status to revoked\nSet revokedAt + revokedById]
    D --> E[Token immediately invalidated]
    E --> F[Done]
Loading

7. Tenant Switch Flow

flowchart TD
    A[User switches tenant] --> B[Frontend sets new x-tenant-id]
    B --> C[Request hits tenant guard]
    C --> D[Resolve tenant from x-tenant-id]
    D --> E[Validate user is tenant member]
    E --> F[Update lastTenantId on User]
    F --> G[Continue to controller]
Loading

8. Project Access Flow

flowchart TD
    A[Request with x-tenant-id\n+ projectId param] --> B[Tenant guard\nresolve tenant context]
    B --> C{Is user tenant owner\nor admin?}
    C -- Yes --> D[Grant project admin access\nauto, no DB check needed]
    C -- No --> E[Check ProjectMember record]
    E --> F{Is user project member?}
    F -- Yes --> G[Grant access\nbased on project role]
    F -- No --> H[403 Forbidden]
    D --> I[Continue to controller]
    G --> I
Loading

Gzerox added 30 commits February 9, 2026 20:31
Added `TenantAuthService` for user authentication and tenant membership validation. Introduced new DTOs, controller, and documentation to support login with tenant credential validation. Updated `TenantModule` to include the new service and controller.
Added support for `EnumRoleScope` to distinguish between platform and tenant-level roles across the system. Updated role repository, tenant and role services, and Prisma schema to reflect the changes. Expanded tenant controller endpoints and adjusted existing logic to handle role scope validation and enumeration. Updated related documentation and seed data.
Added support for Just-in-Time (JIT) tenant access with the `tenant-platform-support` role, enabling platform admins to assume temporary access to tenants. Includes role-based time-limited membership creation, expiration handling, manual revocation, and auditing. Updated schemas, controllers, services, and documentation accordingly.
…ervice`

Moved tenant member management responsibilities (e.g., add, update, delete, JIT access) from `TenantService` to a dedicated `TenantMemberService`. Updated controllers, guards, and interfaces to reflect the separation of concerns.
Added a new `ProjectModule` enabling tenants to manage projects. Features include project creation, updates, deletion, role-based access control, sharing, and API endpoints to interact with projects.
…ionality

Deleted `ProjectShare`, related enums, guards, decorators, and schema references. Simplified `ProjectModule` by focusing on member-based access control. Cleaned up Prisma schema to reflect these changes.
@Gzerox

Gzerox commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Hello @andrechristikan ,
Im planning to work on the next few months on some new stuff, which are based on some of the capabilities of this PR.
I wanted to check with you if there is anything I can do to support you on concluding the review/rework on this PR.

Thanks :)

@Gzerox

Gzerox commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

unbelievable @andrechristikan , while I was writing the previous comment you just committed a lot of work :D

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants