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{Has Invite Code?}
%% Door A: Site Owner Flow
B -- No (Site Owner) --> C[Create User Record]
C --> D[Create Team Record 'Personal Workspace']
D --> E[API Call: Create Virtuozzo Account]
E --> F[Start 3-Day Trial / Billing]
F --> G[Login Success]
%% Door B: Invited Member Flow
B -- Yes (Invited Member) --> H[Validate Invite Token]
H --> I[Create User Record ONLY]
I --> J[SKIP Team Creation]
J --> K[SKIP Virtuozzo Account]
K --> L[Link User to Inviter's Team]
L --> M[Login Success (Context: Inviter's Team)]
- Door A (Site Owner): Creates the billing entity and infrastructure account immediately.
- Door B (Invited Member): Creates a lightweight identity. No billing, no personal infrastructure. They "live" inside the Inviter's account.
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:
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¶
-
The Permissions Table (Static): Populated by developers via migration.
billing.view,billing.editserver.create,server.restart,server.deleteteam.invite,team.remove
-
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.*.
-
The Editing Workflow:
- Goal: Site Owner wants to stop Managers from seeing Billing.
- Action: Frontend calls
DELETE /api/teams/{id}/roles/manager/permissionswith 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.
- Redis Key:
vz_session:{team_id} - 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
Usermodel (no billing fields). - Implement
Teammodel (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/loginreturningpre_auth_token. - Implement
POST /auth/sessionexchanging token for Session Cookie. - Implement Middleware to decode Session Cookie ->
request.state.user.
Phase 3: Granular Permissions¶
- Create
permissionstable and seed with list (billing, server, team). - Create
rolestable (team_id, name, is_editable). - Create
role_permissionstable. - Create API endpoints for Owners to edit Role Permissions (
PUT /teams/{id}/roles/{role_id}).
Phase 4: The Virtuozzo Proxy¶
- Implement
VZSessionManagerclass. - Implement Redis caching logic.
- Implement the
try/catch 401 -> re-login -> retryloop.