Workspaces
Multi-tenant architecture for data isolation.
Overview
ReadyKit uses workspaces as the foundation for multi-tenancy. Every piece of business data belongs to a workspace, ensuring complete isolation between customers.
How It Works
User → Membership → Workspace
↓
role: admin | member- Users can belong to multiple workspaces
- Each workspace has one owner (the creator)
- Members have either
adminormemberrole - All business data is scoped to a workspace
Automatic Workspace Creation
When users sign up (email or OAuth), a workspace is automatically created for them:
# Happens automatically on registration/OAuth
workspace = WorkspaceService.create_workspace(
name="", # Ignored when auto_name=True
owner_user=user,
auto_name=True # Generates "John's Workspace" from user data
)The auto-generated name uses:
- User's name if available →
"John's Workspace" - Email prefix as fallback →
"john's Workspace" - Default →
"My Workspace"
Invisible Workspaces for Solo Users
Solo users never see workspace UI. When a user has only one workspace, they're automatically redirected to it:
workspaces = current_user.get_workspaces()
if len(workspaces) == 1:
return redirect(url_for("portal.switch_workspace", workspace_id=workspaces[0].id))Team features (member list, billing, settings) only appear when users invite team members.
Route Protection
Always use the @require_workspace_access() decorator on workspace routes:
from enferno.services.workspace import require_workspace_access
from flask import g
@app.get("/workspace/<int:workspace_id>/projects/")
@require_workspace_access("member") # or "admin" for admin-only routes
def list_projects(workspace_id):
# Security checks already performed:
# ✓ User is authenticated
# ✓ Workspace exists
# ✓ User is a member
# ✓ User has required role
# Access workspace context:
workspace = g.current_workspace
role = g.user_workspace_role
return render_template("projects.html", workspace=workspace)What the Decorator Does
- Verifies user is authenticated
- Fetches workspace from URL parameter
- Checks user has membership in workspace
- Validates role requirement (
adminormember) - Sets session and context:
session["current_workspace_id"]g.current_workspaceg.user_workspace_role
Creating Workspace-Scoped Models
All business data should inherit from WorkspaceScoped mixin:
from enferno.services.workspace import WorkspaceScoped
from enferno.extensions import db
class Project(db.Model, WorkspaceScoped):
id = db.Column(db.Integer, primary_key=True)
workspace_id = db.Column(db.Integer, db.ForeignKey('workspace.id'), nullable=False)
name = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text)
# Relationship to workspace
workspace = db.relationship('Workspace', backref='projects')WARNING
Always include workspace_id as a non-nullable foreign key. This ensures data isolation at the database level.
Querying Workspace Data
The WorkspaceScoped mixin provides convenient query methods:
# Get all records for current workspace
projects = Project.for_current_workspace()
# Get specific record (workspace-scoped)
project = Project.get_by_id(project_id) # Returns None if not in current workspaceFor custom queries, use the helper function:
from enferno.services.workspace import workspace_query
# Build workspace-scoped query
stmt = workspace_query(Project).where(Project.status == "active")
active_projects = db.session.execute(stmt).scalars().all()Workspace Service Methods
from enferno.services.workspace import WorkspaceService
# Create workspace
ws = WorkspaceService.create_workspace(
name="Acme Corp",
owner_user=user,
auto_name=False
)
# Add member
WorkspaceService.add_member(workspace_id, user, role="member")
db.session.commit() # Caller must commit
# Remove member (cannot remove owner)
WorkspaceService.remove_member(workspace_id, user_id)
# Change role (cannot change owner's role)
WorkspaceService.update_member_role(workspace_id, user_id, "admin")User Model Methods
# Get all workspaces user belongs to
workspaces = current_user.get_workspaces()
# Get user's role in a specific workspace
role = current_user.get_workspace_role(workspace_id) # Returns "admin" or "member"Template Context
get_current_workspace() is globally available in all templates:
{% if get_current_workspace() %}
<h1>{{ get_current_workspace().name }}</h1>
<p>Plan: {{ get_current_workspace().plan }}</p>
{% endif %}Security Best Practices
Always use the decorator
Never access workspace data without @require_workspace_access(). The decorator handles all security checks.
Don't trust session alone
session["current_workspace_id"] can be stale. The decorator re-validates membership on every request.
Include workspace_id in queries
Even with the mixin, always scope queries to workspace_id. Defense in depth.
Validate ownership for destructive actions
For delete/modify operations, verify the record's workspace_id matches the current workspace.