Skip to content

This is the Master Technical Design Document (TDD). It consolidates every architectural decision we have made regarding the Forked Registration, the Backend Proxy, and specifically the Editable Granular Permission System.

This document is ready for your stakeholders and serves as the source of truth for your engineering team.


📘 Technical Design Document: MBPanel Core Architecture

Project: MBPanel (Multi-Tenant SaaS Platform) Version: 2.0 (Finalized) Scope: Authentication, Team Management, RBAC/PBAC, Virtuozzo Proxy.


1. Executive System Overview

MBPanel uses a Team-Centric Architecture where identity (User) is decoupled from billing and resources (Team). This ensures that user accounts are portable across multiple teams and prevents access lockouts due to subscription expiry.

The system employs a Backend-for-Frontend (BFF) security model. The frontend never interacts directly with infrastructure providers (Virtuozzo); instead, the FastAPI backend acts as a secure, self-healing proxy.

1.1 High-Level Architecture

  • Frontend (Next.js): Stateless UI. authentication via HttpOnly Cookies.
  • Backend (FastAPI): Enforces RBAC/PBAC, manages sessions, and proxies infrastructure calls.
  • State (Redis): Caches the shared "Singleton" Virtuozzo session keys.
  • Infrastructure (Virtuozzo): Accessed ONLY via the Backend Proxy.

2. The "Forked" Registration Architecture

To prevent database bloat and "Ghost Accounts" in Virtuozzo, we implement two distinct entry paths.

2.1 Logical Flow Diagram

graph TD
    A[User Entry] --> B{Registration Type?}

    %% Door A1: New Customer Flow
    B -- New Customer --> C1[Create User + Team]
    C1 --> D1[Send Email Verification]
    D1 --> E1[User Verifies Email]
    E1 --> F1[Backend Creates VZ Account]
    F1 --> G1[Store VZ Credentials Encrypted]
    G1 --> H1[Login Success]

    %% Door A2: Migrating Customer Flow
    B -- Migrating from Legacy --> C2[Validate VZ Credentials]
    C2 --> D2[Create User + Team]
    D2 --> E2[Store VZ Credentials Encrypted]
    E2 --> F2[Send Email Verification]
    F2 --> G2[Login Success]

    %% Door B: Invited Member Flow
    B -- Has Invite Token --> C3[Validate Invite Token]
    C3 --> D3[Create User Record ONLY]
    D3 --> E3[SKIP Team Creation]
    E3 --> F3[SKIP Virtuozzo Account]
    F3 --> G3[Link to Inviter's Team]
    G3 --> H3[Login Success]
  • Door A1 (New Customer): Creates user + team. VZ account created after email verification.
  • Door A2 (Migrating Customer): Validates existing VZ credentials, creates user + team with VZ data.
  • Door B (Invited Member): Creates lightweight identity only. No billing, no personal infrastructure.

3. Authentication & Session Strategy

We utilize a Two-Step "Pre-Auth" Flow to secure the transition between verifying credentials and selecting a team context.

3.1 The Login Flow Diagram

sequenceDiagram
    participant Client as Next.js
    participant Auth as FastAPI Auth
    participant DB as Database

    %% Step 1
    Client->>Auth: POST /auth/login {email, password}
    Auth->>DB: Validate Creds & Fetch Active Teams
    DB-->>Auth: User Valid, Teams List
    Auth-->>Client: 200 OK { pre_auth_token, available_teams }

    Note right of Client: pre_auth_token scope: 'team_select_only'<br/>Expires in 5 mins.

    %% Step 2
    Client->>Auth: POST /auth/session { team_id: 5 }
    Note right of Client: Header: Bearer <pre_auth_token>

    Auth->>DB: Verify User Membership in Team 5
    Auth->>DB: Verify Team 5 is ACTIVE (Not Expired)
    DB-->>Auth: Allowed

    Auth-->>Client: 200 OK (Set HttpOnly Cookie: Access Token)

3.2 Access Token Structure (JWT)

The final access_token contains the context required for RBAC:

{
  "sub": "user_101",
  "team_id": 50,
  "role": "manager",  // Cached role name
  "exp": 1710000000
}

4. Granular Permission System (PBAC)

This section addresses the requirement for Pre-Defined Roles with Granular Editing.

We do not check "Roles" in our code. We check "Permissions". Roles are simply database containers that group permissions together.

4.1 Database Schema (ERD)

erDiagram
    TEAMS ||--o{ ROLES : defines
    ROLES ||--o{ ROLE_PERMISSIONS : has
    PERMISSIONS ||--o{ ROLE_PERMISSIONS : links
    TEAM_MEMBERS }o--|| ROLES : assigned

    TEAMS {
        int id
        string name
    }
    ROLES {
        int id
        int team_id
        string name
        boolean is_editable
        string description
    }
    PERMISSIONS {
        string slug "PK (e.g. billing.view)"
        string description
    }
    ROLE_PERMISSIONS {
        int role_id
        string permission_slug
    }

4.2 The Logic: Editable vs. Static

  1. The Permissions Table (Static): Populated by developers via migration.

    • billing.view, billing.edit
    • server.create, server.restart, server.delete
    • team.invite, team.remove
  2. The Roles Table (Dynamic per Team):

    • When a Team is created, we seed it with default roles.
    • Owner (is_editable=False): Has * (All permissions). Cannot be changed.
    • Manager (is_editable=True): Seeded with billing.*, server.*, team.*.
    • Developer (is_editable=True): Seeded with server.*.
  3. The Editing Workflow:

    • Goal: Site Owner wants to stop Managers from seeing Billing.
    • Action: Frontend calls DELETE /api/teams/{id}/roles/manager/permissions with body ['billing.view', 'billing.edit'].
    • Backend: Removes rows from ROLE_PERMISSIONS.
    • Result: Any user with the "Manager" role instantly loses access to billing endpoints.

4.3 Code Implementation (The Enforcer)

We use a FastAPI Dependency to enforce permissions, not roles.

# backend/core/security.py
def require_permission(required_perm: str):
    def dependency(request: Request, db: Session = Depends(get_db)):
        user = request.state.user
        team_id = request.state.team_id

        # 1. Check DB: Does the user's current Role have this Permission?
        has_perm = db.query(RolePermission).join(TeamMember).filter(
            TeamMember.user_id == user.id,
            TeamMember.team_id == team_id,
            RolePermission.permission_slug == required_perm
        ).exists()

        if not has_perm:
            raise HTTPException(403, "Permission Denied")
    return dependency

# backend/api/billing.py
@router.get("/invoices")
def get_invoices(_ = Depends(require_permission("billing.view"))):
    return {"invoices": [...]}

5. The Infrastructure Proxy (Virtuozzo Integration)

This acts as the bridge between our secure internal logic and the external infrastructure.

5.1 The "Lazy Singleton" Pattern

We treat the Virtuozzo Session Key as a Team Resource, not a User Resource.

  1. Redis Key: vz_session:{team_id}
  2. TTL: 25 Minutes (VZ expires in 30 mins).

5.2 Self-Healing Workflow Diagram

sequenceDiagram
    participant API as FastAPI Endpoint
    participant Proxy as VZ Manager Service
    participant Redis as Redis Cache
    participant VZ as Virtuozzo API

    API->>Proxy: execute_request(restart_server)

    rect rgb(240, 248, 255)
    Note over Proxy, Redis: 1. Try Cache
    Proxy->>Redis: GET vz_session:{team_id}
    Redis-->>Proxy: Return SessionKey "ABC"
    end

    rect rgb(255, 240, 240)
    Note over Proxy, VZ: 2. Execute with Key
    Proxy->>VZ: POST /restart (Session="ABC")
    VZ-->>Proxy: 401 Unauthorized (Session Expired)
    end

    rect rgb(240, 255, 240)
    Note over Proxy, VZ: 3. Self-Healing Triggered
    Proxy->>Proxy: Fetch DB Creds & Decrypt
    Proxy->>VZ: Login(User, Pass)
    VZ-->>Proxy: 200 OK (NewSession="XYZ")
    Proxy->>Redis: SET vz_session:{team_id} = "XYZ"
    end

    rect rgb(240, 248, 255)
    Note over Proxy, VZ: 4. Retry Original Request
    Proxy->>VZ: POST /restart (Session="XYZ")
    VZ-->>Proxy: 200 OK (Success)
    end

    Proxy-->>API: Return Data

6. Implementation Task Checklist

Phase 1: Core Identity (Door A / Door B)

  • Implement User model (no billing fields).
  • Implement Team model (status: active/trial/archived).
  • Create POST /register (Owner flow - creates Team + User).
  • Create POST /invite/accept (Member flow - creates User only).

Phase 2: Authentication

  • Implement POST /auth/login returning pre_auth_token.
  • Implement POST /auth/session exchanging token for Session Cookie.
  • Implement Middleware to decode Session Cookie -> request.state.user.

Phase 3: Granular Permissions

  • Create permissions table and seed with list (billing, server, team).
  • Create roles table (team_id, name, is_editable).
  • Create role_permissions table.
  • Create API endpoints for Owners to edit Role Permissions (PUT /teams/{id}/roles/{role_id}).
  • Create frontend RoleManagement component with permission tooltips.
  • Create custom role creation dialog with permission checkboxes.

Phase 4: The Virtuozzo Proxy

  • Implement VZSessionManager class.
  • Implement Redis caching logic.
  • Implement the try/catch 401 -> re-login -> retry loop.

7. Permissions System Implementation Guide

7.1 Default Roles and Permissions

When a new team is created, the system automatically seeds three default roles:

Owner Role

  • Permissions: * (wildcard - all permissions)
  • Editable: No (system-protected)
  • Description: Owner role with full control

Manager Role

  • Permissions:
  • team.manage - Manage team settings and roles
  • team.invite - Invite team members
  • events:read - Read SSE events
  • billing.view - View billing information
  • billing.edit - Edit billing settings
  • server.create - Create new servers
  • server.restart - Restart servers
  • server.delete - Delete servers
  • Editable: Yes
  • Description: Manager role with team and infrastructure management

Developer Role

  • Permissions:
  • events:read - Read SSE events
  • server.create - Create new servers
  • server.restart - Restart servers
  • server.delete - Delete servers
  • Editable: Yes
  • Description: Developer role with server management only

7.2 Permission Catalog

All available permissions are seeded in the database via Alembic migration:

# Migration: 2025_12_17_0002_seed_permissions_catalog.py
_PERMISSIONS = [
    {"slug": "*", "description": "All permissions (wildcard)"},
    {"slug": "team.manage", "description": "Manage team (ownership/roles/settings)"},
    {"slug": "team.invite", "description": "Invite team members"},
    {"slug": "events:read", "description": "Read SSE events"},
    {"slug": "billing.view", "description": "View billing"},
    {"slug": "billing.edit", "description": "Edit billing"},
    {"slug": "server.create", "description": "Create server"},
    {"slug": "server.restart", "description": "Restart server"},
    {"slug": "server.delete", "description": "Delete server"},
]

7.3 Frontend Implementation

Using the RoleManagement Component

import { RoleManagement } from '@/features/teams/components';

function TeamSettingsPage() {
  const user = useCurrentUser();
  const teamId = user.team_id;

  return (
    <RoleManagement
      teamId={teamId}
      currentUserPermissions={user.permissions}
    />
  );
}

Component Features

  1. Role Display with Hover Tooltips
  2. Shows role name, description, and editable status
  3. Displays first 3 permissions as badges
  4. On hover, shows complete list of permissions with descriptions in a tooltip

  5. Custom Role Creation

  6. Dialog with form for role name and description
  7. Grouped permission checkboxes by category (team, billing, server, events)
  8. Select All / Deselect All functionality
  9. Real-time permission count display

  10. Permission Editing

  11. Edit existing role permissions (for editable roles only)
  12. Same checkbox interface as creation dialog
  13. Preserves role name and description

  14. Role Deletion

  15. Delete custom roles (with confirmation)
  16. Protected system roles cannot be deleted
  17. Prevents deletion of roles currently assigned to members

Available Service Methods

// Get all permissions
await teamService.getPermissions();

// Get roles for a team
await teamService.getRoles(teamId);

// Create custom role
await teamService.createRole(
  teamId,
  'Support Team',
  'Customer support role',
  ['billing.view', 'server.restart']
);

// Update role permissions
await teamService.updateRolePermissions(
  teamId,
  roleId,
  ['billing.view', 'billing.edit', 'server.restart']
);

// Delete role
await teamService.deleteRole(teamId, roleId);

// Assign role to member
await teamService.assignMemberRole(teamId, userId, roleId);

7.4 Security Considerations

  1. Session Revocation: When role permissions change, all user sessions for members with that role are automatically revoked to ensure immediate permission updates.

  2. Owner Protection: The Owner role with wildcard permissions cannot be edited or deleted.

  3. In-Use Protection: Roles currently assigned to team members cannot be deleted.

  4. Permission Caching: Permissions are cached in JWT tokens. The backend automatically revokes tokens when permissions change.