Files
ANDREW HOSFORD 0e8978f48c Update project files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:59:48 -05:00

1306 lines
51 KiB
Markdown

# Architecture Research: Multi-Tenancy & User Accounts Integration
**Domain:** Requirements management system (FastAPI + SQLAlchemy 2.0 + SQLite)
**Researched:** 2026-03-18
**Confidence:** HIGH
## Executive Summary
Adding user accounts and multi-tenancy to an existing FastAPI + SQLAlchemy + SQLite application requires four architectural layers: **Authentication** (user identity), **Authorization** (permissions), **Tenant Isolation** (data separation), and **Migration Strategy** (backwards compatibility). The recommended approach uses shared-schema multi-tenancy with tenant-scoped queries, JWT tokens with refresh tokens, RBAC via junction tables, and FastAPI's dependency injection for context management.
**Key decision:** Use shared-schema multi-tenancy (single database, user_id + tenant_id filtering) rather than database-per-tenant, because SQLite's single-writer lock makes multiple active databases perform worse than filtered queries on a single database.
## Existing Architecture Analysis
### Current State
```
┌─────────────────────────────────────────────────────────────┐
│ Angular 17 Frontend │
│ - AuthGuard + HTTP Interceptor for token injection │
│ - localStorage for token storage │
└─────────────────────┬───────────────────────────────────────┘
│ HTTP/JSON
┌─────────────────────┴───────────────────────────────────────┐
│ FastAPI Backend │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ auth.py: Single password → JWT (24h, no refresh) │ │
│ │ verify_token() dependency on all protected routes │ │
│ └────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Routers: requirements, projects, links, layouts, │ │
│ │ stats (all protected by verify_token) │ │
│ └────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ SQLAlchemy 2.0 ORM with get_db() dependency │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────┬───────────────────────────────────────┘
┌─────────────────────┴───────────────────────────────────────┐
│ SQLite Database │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ requirement_nodes│ │ projects │ │
│ │ (no user_id) │ │ (no user_id) │ │
│ └──────────────────┘ └──────────────────┘ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ cross_pillar_links│ │ requirement_history│ │
│ │ │ │ (no user_id) │ │
│ └──────────────────┘ └──────────────────┘ │
│ ┌──────────────────┐ │
│ │ graph_layouts │ │
│ │ (no user_id) │ │
│ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### Architecture Gaps
| Component | Current State | Required for v2.0 |
|-----------|---------------|-------------------|
| **Authentication** | Single password, 24h JWT | User accounts with email/password, refresh tokens |
| **User Model** | None | Users table with hashed passwords |
| **Tenant Isolation** | None (all data shared) | Per-user project ownership, user_id filtering |
| **Authorization** | Binary (authenticated or not) | Role-based access (owner/editor/viewer per project) |
| **Data Attribution** | No edit tracking | User attribution in history |
| **Invite System** | N/A | Invite-only registration with tokens |
| **Session Management** | Stateless JWT (24h only) | Refresh tokens (long-lived, revocable) |
## Recommended Architecture
### Target State with Multi-Tenancy
```
┌─────────────────────────────────────────────────────────────┐
│ Angular 17 Frontend │
│ - AuthGuard with user profile │
│ - HTTP Interceptor with token refresh logic │
│ - localStorage: access_token + refresh_token │
└─────────────────────┬───────────────────────────────────────┘
│ HTTP/JSON
┌─────────────────────┴───────────────────────────────────────┐
│ FastAPI Backend │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Authentication Layer (auth.py + new users.py) │ │
│ │ - POST /auth/register (invite token required) │ │
│ │ - POST /auth/login (email + password → tokens) │ │
│ │ - POST /auth/refresh (refresh_token → new access) │ │
│ │ - POST /auth/logout (revoke refresh_token) │ │
│ │ - GET /auth/me (current user profile) │ │
│ └────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Dependency Injection Context │ │
│ │ get_current_user() → UserContext (user_id, email) │ │
│ │ require_project_access(role) → enforces RBAC │ │
│ └────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Tenant-Scoped Query Middleware │ │
│ │ Auto-inject user_id filter on all queries │ │
│ │ (via SQLAlchemy session events) │ │
│ └────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Routers (modified for user context) │ │
│ │ - requirements: add created_by, updated_by │ │
│ │ - projects: add owner_id, check permissions │ │
│ │ - links, layouts, stats: scope to accessible projects │ │
│ └────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ SQLAlchemy 2.0 ORM │ │
│ │ get_db_with_tenant_filter() → scoped session │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────┬───────────────────────────────────────┘
┌─────────────────────┴───────────────────────────────────────┐
│ SQLite Database │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ users │ │ projects │ │
│ │ (NEW) │ │ + owner_id (FK) │ │
│ └──────────────────┘ └──────────────────┘ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ project_members │ │ requirement_nodes│ │
│ │ (NEW - RBAC) │ │ + created_by_id │ │
│ │ │ │ + updated_by_id │ │
│ └──────────────────┘ └──────────────────┘ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ invite_tokens │ │ requirement_history│ │
│ │ (NEW) │ │ + user_id │ │
│ └──────────────────┘ └──────────────────┘ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ refresh_tokens │ │ graph_layouts │ │
│ │ (NEW) │ │ (no change) │ │
│ └──────────────────┘ └──────────────────┘ │
│ ┌──────────────────┐ │
│ │ cross_pillar_links│ (no change - inherits access from │
│ │ │ parent requirement) │
│ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
## New Database Schema
### New Tables
#### 1. users (Core Identity)
```python
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
full_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False) # Global admin flag
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
last_login: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Relationships
owned_projects: Mapped[list["Project"]] = relationship("Project", back_populates="owner")
project_memberships: Mapped[list["ProjectMember"]] = relationship(
"ProjectMember", back_populates="user", cascade="all, delete-orphan"
)
created_requirements: Mapped[list["RequirementNode"]] = relationship(
"RequirementNode", foreign_keys="RequirementNode.created_by_id", back_populates="created_by"
)
refresh_tokens: Mapped[list["RefreshToken"]] = relationship(
"RefreshToken", back_populates="user", cascade="all, delete-orphan"
)
__table_args__ = (
Index("idx_users_email_active", "email", "is_active"), # Fast login lookup
)
```
**Why this design:**
- `email` as primary identity (unique, indexed for fast login)
- `is_admin` for global operations (e.g., invite token generation)
- `is_active` for soft account suspension without data loss
- `last_login` for monitoring and security auditing
#### 2. project_members (RBAC Junction Table)
```python
class ProjectMemberRole(str, enum.Enum):
owner = "owner" # Full control, can delete project
editor = "editor" # Can modify requirements, cannot manage members
viewer = "viewer" # Read-only access
class ProjectMember(Base):
__tablename__ = "project_members"
project_id: Mapped[int] = mapped_column(
Integer, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True
)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
role: Mapped[ProjectMemberRole] = mapped_column(
Enum(ProjectMemberRole), nullable=False, default=ProjectMemberRole.viewer
)
added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
added_by_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
# Relationships
project: Mapped["Project"] = relationship("Project", back_populates="members")
user: Mapped["User"] = relationship("User", back_populates="project_memberships")
__table_args__ = (
Index("idx_project_members_user", "user_id"), # Find all projects for a user
Index("idx_project_members_project", "project_id"), # Find all members of a project
)
```
**Why this design:**
- Composite primary key (project_id, user_id) prevents duplicate memberships
- Three-tier role model (owner/editor/viewer) covers most collaboration patterns
- `added_by_id` for audit trail (who invited whom)
- Cascade delete ensures cleanup when projects or users are deleted
#### 3. invite_tokens (Invite-Only Registration)
```python
class InviteToken(Base):
__tablename__ = "invite_tokens"
token: Mapped[str] = mapped_column(String(64), primary_key=True) # UUID or secure random
email: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
created_by_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
used_by_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
__table_args__ = (
Index("idx_invite_tokens_email_used", "email", "used_at"), # Check if email already invited
Index("idx_invite_tokens_expires", "expires_at"), # Cleanup expired tokens
)
```
**Why this design:**
- Token as primary key (opaque, unpredictable)
- `email` scoped invites prevent multiple registrations with same token
- `expires_at` for time-limited invites (e.g., 7 days)
- `used_at` / `used_by_id` track invite consumption (one-time use)
- Created by existing users (only authenticated users can invite)
#### 4. refresh_tokens (Session Management)
```python
class RefreshToken(Base):
__tablename__ = "refresh_tokens"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
token_hash: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Relationships
user: Mapped["User"] = relationship("User", back_populates="refresh_tokens")
__table_args__ = (
Index("idx_refresh_tokens_user_active", "user_id", "revoked_at"), # Active tokens per user
Index("idx_refresh_tokens_expires", "expires_at"), # Cleanup expired tokens
)
```
**Why this design:**
- `token_hash` stores hashed refresh token (never store plaintext tokens in DB)
- `expires_at` for long-lived but not permanent tokens (e.g., 30 days)
- `revoked_at` enables explicit logout (revoke token)
- `last_used_at` for security monitoring (detect stolen tokens via unusual usage patterns)
- Multiple refresh tokens per user allowed (multi-device support)
### Modified Existing Tables
#### projects (Add Ownership)
```python
# ADD to existing Project model:
owner_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
owner: Mapped["User"] = relationship("User", back_populates="owned_projects")
members: Mapped[list["ProjectMember"]] = relationship(
"ProjectMember", back_populates="project", cascade="all, delete-orphan"
)
```
**Migration strategy:**
- New column `owner_id` with `server_default=1` (assumes first admin user has id=1)
- Run data migration to assign existing projects to the admin user
- After migration, make `owner_id` NOT NULL
#### requirement_nodes (Add Attribution)
```python
# ADD to existing RequirementNode model:
created_by_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
updated_by_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
created_by: Mapped["User | None"] = relationship(
"User", foreign_keys=[created_by_id], back_populates="created_requirements"
)
updated_by: Mapped["User | None"] = relationship(
"User", foreign_keys=[updated_by_id]
)
```
**Migration strategy:**
- Both columns nullable (existing data has NULL creator)
- `SET NULL` on user deletion preserves history even if user deleted
- Display "Unknown User" in UI when created_by_id is NULL
#### requirement_history (Add User Attribution)
```python
# ADD to existing RequirementHistory model:
user_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
user: Mapped["User | None"] = relationship("User")
```
**Migration strategy:**
- Nullable because historical edits have no user attribution
- New history entries always have user_id (enforced in router logic)
## Integration Patterns
### Pattern 1: User Context Injection (Dependency Injection)
**What:** Extract authenticated user from JWT and provide as dependency to route handlers.
**Implementation:**
```python
# In auth.py
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from pydantic import BaseModel
security = HTTPBearer()
class UserContext(BaseModel):
user_id: int
email: str
is_admin: bool
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
) -> UserContext:
"""Extract user from JWT access token."""
try:
payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
user_id: int = payload.get("sub")
if user_id is None:
raise HTTPException(status_code=401, detail="Invalid token")
except JWTError:
raise HTTPException(status_code=401, detail="Invalid or expired token")
# Verify user exists and is active
user = db.query(User).filter(User.id == user_id, User.is_active == True).first()
if not user:
raise HTTPException(status_code=401, detail="User not found or inactive")
return UserContext(user_id=user.id, email=user.email, is_admin=user.is_admin)
# Usage in routes:
@router.get("/api/projects")
def list_projects(
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_user)
):
# current_user available here
pass
```
**Why this works:**
- FastAPI's dependency injection runs get_current_user before route handler
- Raises HTTPException if token invalid (automatic 401 response)
- UserContext is immutable (Pydantic model) preventing tampering
- No middleware needed—explicit dependencies are clearer
### Pattern 2: RBAC Authorization (Permission Checking)
**What:** Check user's role on a project before allowing operations.
**Implementation:**
```python
# In auth.py
from functools import wraps
from typing import Callable
class PermissionDenied(HTTPException):
def __init__(self):
super().__init__(status_code=403, detail="Insufficient permissions")
def require_project_access(
project_id: int,
required_role: ProjectMemberRole,
db: Session,
current_user: UserContext
) -> ProjectMember:
"""
Check if user has required role on project.
Returns ProjectMember if authorized, raises 403 otherwise.
Role hierarchy: owner > editor > viewer
"""
# Global admins bypass all checks
if current_user.is_admin:
return ProjectMember(
project_id=project_id,
user_id=current_user.user_id,
role=ProjectMemberRole.owner
)
# Check ownership
project = db.query(Project).filter(Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
if project.owner_id == current_user.user_id:
return ProjectMember(
project_id=project_id,
user_id=current_user.user_id,
role=ProjectMemberRole.owner
)
# Check membership
member = db.query(ProjectMember).filter(
ProjectMember.project_id == project_id,
ProjectMember.user_id == current_user.user_id
).first()
if not member:
raise PermissionDenied()
# Role hierarchy check
role_order = {
ProjectMemberRole.viewer: 1,
ProjectMemberRole.editor: 2,
ProjectMemberRole.owner: 3,
}
if role_order[member.role] < role_order[required_role]:
raise PermissionDenied()
return member
# Usage in routes:
@router.delete("/api/requirements/{req_id}")
def delete_requirement(
req_id: str,
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_user)
):
req = db.query(RequirementNode).filter(RequirementNode.id == req_id).first()
if not req:
raise HTTPException(status_code=404)
# Check editor access on project
require_project_access(
project_id=req.project_id,
required_role=ProjectMemberRole.editor,
db=db,
current_user=current_user
)
# Authorized—proceed with deletion
db.delete(req)
db.commit()
```
**Why this works:**
- Explicit permission checks at operation level (not blanket middleware)
- Role hierarchy encoded in single function (DRY principle)
- Global admins bypass for administrative operations
- Project owners inherit full access automatically
### Pattern 3: Tenant-Scoped Queries (Data Isolation)
**What:** Automatically filter queries to only return data user has access to.
**Implementation (Option A: Manual Filtering - Recommended for SQLite):**
```python
# In routers/projects.py
@router.get("/api/projects", response_model=list[ProjectResponse])
def list_projects(
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_user)
):
"""List all projects accessible to current user."""
if current_user.is_admin:
# Admins see all projects
projects = db.query(Project).all()
else:
# Users see owned + member projects
owned = db.query(Project).filter(Project.owner_id == current_user.user_id)
member = (
db.query(Project)
.join(ProjectMember)
.filter(ProjectMember.user_id == current_user.user_id)
)
projects = owned.union(member).all()
return projects
```
**Implementation (Option B: SQLAlchemy Session Event Hooks - Advanced):**
```python
# In database.py
from sqlalchemy import event
from sqlalchemy.orm import Session
class TenantSession(Session):
"""Session subclass that auto-filters queries by user_id."""
def __init__(self, user_id: int | None = None, **kwargs):
super().__init__(**kwargs)
self.user_id = user_id
@event.listens_for(TenantSession, "do_orm_execute")
def receive_do_orm_execute(execute_state):
"""Intercept queries and inject tenant filters."""
if execute_state.is_select and execute_state.session.user_id:
# Auto-add user_id filter to all queries on tables with user_id column
# (Complex implementation—see SQLAlchemy docs)
pass
# In dependency:
def get_tenant_db(current_user: UserContext = Depends(get_current_user)):
db = TenantSession(user_id=current_user.user_id, bind=engine)
try:
yield db
finally:
db.close()
```
**Recommendation:** Use **Option A (Manual Filtering)** for this project because:
- SQLite doesn't support Row Level Security like PostgreSQL
- Explicit filters are easier to understand and debug
- Session event hooks add complexity and can break with ORM updates
- Project has moderate query count—manual filters are manageable
### Pattern 4: Refresh Token Rotation (Session Security)
**What:** Short-lived access tokens (15min) with long-lived refresh tokens (30 days).
**Implementation:**
```python
# In auth.py
ACCESS_TOKEN_EXPIRE_MINUTES = 15
REFRESH_TOKEN_EXPIRE_DAYS = 30
def create_access_token(user_id: int) -> str:
"""Short-lived token for API requests."""
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {"sub": user_id, "exp": expire, "type": "access"}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def create_refresh_token(db: Session, user_id: int) -> str:
"""Long-lived token for obtaining new access tokens."""
token = secrets.token_urlsafe(32) # Cryptographically secure random
token_hash = hashlib.sha256(token.encode()).hexdigest()
expires_at = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
db_token = RefreshToken(
user_id=user_id,
token_hash=token_hash,
expires_at=expires_at
)
db.add(db_token)
db.commit()
return token # Return plaintext (only time it's visible)
@router.post("/auth/refresh", response_model=TokenResponse)
def refresh_access_token(
refresh_token: str,
db: Session = Depends(get_db)
):
"""Exchange refresh token for new access token."""
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
db_token = db.query(RefreshToken).filter(
RefreshToken.token_hash == token_hash,
RefreshToken.revoked_at.is_(None),
RefreshToken.expires_at > datetime.now(timezone.utc)
).first()
if not db_token:
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
# Update last used timestamp
db_token.last_used_at = datetime.now(timezone.utc)
db.commit()
# Generate new access token
access_token = create_access_token(db_token.user_id)
return TokenResponse(access_token=access_token)
@router.post("/auth/logout")
def logout(
refresh_token: str,
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_user)
):
"""Revoke refresh token (logout)."""
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
db_token = db.query(RefreshToken).filter(
RefreshToken.token_hash == token_hash,
RefreshToken.user_id == current_user.user_id
).first()
if db_token:
db_token.revoked_at = datetime.now(timezone.utc)
db.commit()
return {"message": "Logged out"}
```
**Frontend integration (Angular):**
```typescript
// In auth.interceptor.ts
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
return next.handle(this.addToken(req)).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401 && this.getRefreshToken()) {
return this.handle401Error(req, next);
}
return throwError(() => error);
})
);
}
private handle401Error(req: HttpRequest<any>, next: HttpHandler) {
if (!this.isRefreshing) {
this.isRefreshing = true;
this.refreshTokenSubject.next(null);
return this.authService.refreshToken().pipe(
switchMap((token: any) => {
this.isRefreshing = false;
this.refreshTokenSubject.next(token.access_token);
return next.handle(this.addToken(req));
}),
catchError((err) => {
this.isRefreshing = false;
this.authService.logout();
return throwError(() => err);
})
);
}
return this.refreshTokenSubject.pipe(
filter(token => token !== null),
take(1),
switchMap(() => next.handle(this.addToken(req)))
);
}
```
**Why this works:**
- Access tokens short-lived (minimal damage if stolen)
- Refresh tokens stored in httpOnly cookies or secure storage (harder to steal)
- Token rotation prevents replay attacks
- Explicit revocation enables secure logout
### Pattern 5: Invite-Only Registration Flow
**What:** Only users with valid invite tokens can register.
**Implementation:**
```python
# In auth.py
@router.post("/auth/invites", dependencies=[Depends(get_current_user)])
def create_invite(
email: str,
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_user)
):
"""Create invite token (authenticated users only)."""
# Check if email already registered
if db.query(User).filter(User.email == email).first():
raise HTTPException(status_code=400, detail="User already exists")
# Check for unused invite
existing = db.query(InviteToken).filter(
InviteToken.email == email,
InviteToken.used_at.is_(None),
InviteToken.expires_at > datetime.now(timezone.utc)
).first()
if existing:
raise HTTPException(status_code=400, detail="Invite already sent")
# Generate token
token = secrets.token_urlsafe(32)
expires_at = datetime.now(timezone.utc) + timedelta(days=7)
invite = InviteToken(
token=token,
email=email,
created_by_id=current_user.user_id,
expires_at=expires_at
)
db.add(invite)
db.commit()
# TODO: Send email with registration link
# send_invite_email(email, f"https://app.example.com/register?token={token}")
return {"token": token, "expires_at": expires_at}
@router.post("/auth/register", response_model=TokenResponse)
def register(
invite_token: str,
email: str,
password: str,
full_name: str | None,
db: Session = Depends(get_db)
):
"""Register new user with invite token."""
# Validate invite
invite = db.query(InviteToken).filter(
InviteToken.token == invite_token,
InviteToken.email == email,
InviteToken.used_at.is_(None),
InviteToken.expires_at > datetime.now(timezone.utc)
).first()
if not invite:
raise HTTPException(status_code=400, detail="Invalid or expired invite")
# Check if user exists
if db.query(User).filter(User.email == email).first():
raise HTTPException(status_code=400, detail="User already exists")
# Create user
hashed_password = get_password_hash(password)
user = User(
email=email,
hashed_password=hashed_password,
full_name=full_name
)
db.add(user)
# Mark invite as used
invite.used_at = datetime.now(timezone.utc)
invite.used_by_id = user.id
db.commit()
# Generate tokens
access_token = create_access_token(user.id)
refresh_token = create_refresh_token(db, user.id)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token
)
```
**Why this works:**
- Email scoped invites prevent token reuse
- Expiration limits token lifespan
- Audit trail tracks who invited whom
- One-time use prevents invite sharing
## Data Flow Changes
### Before (v1.0 - Single Password)
```
User enters password
POST /api/auth/login {password}
Backend validates against single PASSWORD env var
If valid: JWT created with {exp} only
Frontend stores token in localStorage
All API requests include: Authorization: Bearer <token>
verify_token() checks JWT signature and expiration
If valid: route handler executes (no user context)
```
### After (v2.0 - User Accounts)
```
User enters email + password
POST /api/auth/login {email, password}
Backend queries users table by email
Verify hashed_password with bcrypt
If valid: Generate access_token + refresh_token
Frontend stores both tokens (localStorage or httpOnly cookie)
API requests include: Authorization: Bearer <access_token>
get_current_user() extracts user_id from JWT
Queries users table to verify user still active
Returns UserContext(user_id, email, is_admin)
Route handler receives current_user dependency
Permission checks against project_members table
Queries auto-filtered by user's accessible projects
Write operations tag created_by_id / updated_by_id
```
### New: Token Refresh Flow
```
Access token expires (15min)
Frontend receives 401 on API request
Interceptor catches 401, calls POST /api/auth/refresh {refresh_token}
Backend validates refresh_token (hash lookup in DB)
Checks: not revoked, not expired, user active
If valid: Generate new access_token (refresh_token unchanged)
Updates last_used_at timestamp on refresh_token
Returns new access_token
Frontend retries original request with new token
(Transparent to user—no re-login needed)
```
## Migration Strategy (Backwards Compatibility)
### Phase 1: Add New Tables (Non-Breaking)
```python
# Alembic migration: add users, project_members, invite_tokens, refresh_tokens
# No existing data affected—tables are empty initially
```
### Phase 2: Add Columns with Nullable (Non-Breaking)
```python
# Alembic migration:
# - ALTER TABLE projects ADD COLUMN owner_id INTEGER REFERENCES users(id)
# - ALTER TABLE requirement_nodes ADD COLUMN created_by_id INTEGER REFERENCES users(id)
# - ALTER TABLE requirement_nodes ADD COLUMN updated_by_id INTEGER REFERENCES users(id)
# - ALTER TABLE requirement_history ADD COLUMN user_id INTEGER REFERENCES users(id)
# All columns nullable initially—existing data has NULL
```
### Phase 3: Seed Default Admin User
```python
# Data migration script:
default_user = User(
id=1,
email="admin@localhost",
hashed_password=get_password_hash(os.environ.get("ADMIN_PASSWORD", "changeme")),
full_name="System Admin",
is_admin=True
)
db.add(default_user)
# Assign all existing projects to admin
db.execute(
update(Project).values(owner_id=1)
)
# Mark all existing requirements as created by admin
db.execute(
update(RequirementNode)
.where(RequirementNode.created_by_id.is_(None))
.values(created_by_id=1)
)
db.commit()
```
### Phase 4: Deploy New Auth Endpoints (Dual Auth)
```python
# Keep old /api/auth/login {password} working
# Add new /api/auth/login {email, password}
# Frontend updated to use new endpoint
# Old endpoint deprecated but functional (gradual migration)
```
### Phase 5: Make owner_id NOT NULL (Breaking)
```python
# After confirming all projects have owner_id:
# Alembic migration:
# - ALTER TABLE projects ALTER COLUMN owner_id SET NOT NULL
```
### Rollback Plan
If issues discovered after deployment:
1. **Revert code:** Old auth.py still validates tokens (SECRET_KEY unchanged)
2. **Database intact:** New columns nullable—old queries unaffected
3. **No data loss:** All existing data preserved with owner_id=1
## Build Order (Recommended Sequence)
### Milestone 1: Authentication Foundation
**Goal:** Users can register and login with email/password.
1. Create `User` model and table
2. Create `InviteToken` model and table
3. Create `RefreshToken` model and table
4. Implement password hashing (passlib + bcrypt)
5. Add `/auth/register` endpoint (with invite token validation)
6. Add `/auth/login` endpoint (email + password → tokens)
7. Add `/auth/refresh` endpoint (refresh token → new access token)
8. Add `/auth/logout` endpoint (revoke refresh token)
9. Update `get_current_user()` dependency
10. Seed default admin user in migration
**Test:** User can register with invite, login, and receive valid tokens.
### Milestone 2: Project Ownership
**Goal:** Projects belong to users, users see only their projects.
1. Add `owner_id` column to `projects` (nullable)
2. Update project creation to set `owner_id = current_user.user_id`
3. Update `GET /api/projects` to filter by ownership
4. Update `GET /api/requirements` to filter by project ownership
5. Data migration: Assign existing projects to admin
6. Make `owner_id` NOT NULL
**Test:** User sees only projects they own; other users' projects hidden.
### Milestone 3: RBAC (Project Sharing)
**Goal:** Project owners can invite collaborators with roles.
1. Create `ProjectMember` model and table
2. Add `POST /api/projects/{id}/members` (owner adds member)
3. Add `GET /api/projects/{id}/members` (list members)
4. Add `DELETE /api/projects/{id}/members/{user_id}` (remove member)
5. Implement `require_project_access()` dependency
6. Update all requirement routes to check RBAC
7. Add role field to project list response (user's role per project)
**Test:** Owner invites editor; editor can modify requirements but not delete project.
### Milestone 4: Edit Attribution
**Goal:** History tracks who made each change.
1. Add `created_by_id`, `updated_by_id` to `requirement_nodes`
2. Add `user_id` to `requirement_history`
3. Update requirement creation to set `created_by_id`
4. Update requirement updates to set `updated_by_id`
5. Update history creation to set `user_id`
6. Update UI to display editor names in history
**Test:** History panel shows "Edited by Alice" instead of generic timestamps.
### Milestone 5: Invite System
**Goal:** Users can invite others to join the platform.
1. Add `POST /api/auth/invites` (create invite token)
2. Add `GET /api/auth/invites` (list my sent invites)
3. Add invite email template
4. Integrate email sending (SMTP or service like Mailgun)
5. Update registration flow to send welcome email
**Test:** User creates invite; recipient registers successfully; both users see each other's projects if shared.
## Anti-Patterns to Avoid
### Anti-Pattern 1: Trusting Client-Provided user_id
**What people do:**
```python
@router.post("/api/requirements")
def create_requirement(data: RequirementCreate, user_id: int): # ❌ DANGEROUS
node = RequirementNode(**data.dict(), created_by_id=user_id)
# Attacker can impersonate anyone by sending any user_id
```
**Why it's wrong:** Clients can lie. Attacker sends `user_id=1` to impersonate admin.
**Do this instead:**
```python
@router.post("/api/requirements")
def create_requirement(
data: RequirementCreate,
current_user: UserContext = Depends(get_current_user) # ✅ Server extracts from JWT
):
node = RequirementNode(**data.dict(), created_by_id=current_user.user_id)
```
### Anti-Pattern 2: Global Query Filters in Middleware
**What people do:**
```python
# Middleware that silently filters all queries by user_id
@app.middleware("http")
async def tenant_filter_middleware(request, call_next):
# Inject user_id filter into all SQLAlchemy queries
# (Complex session hook magic)
```
**Why it's wrong:**
- Hidden behavior—hard to debug ("why is this query returning nothing?")
- Breaks when you need cross-tenant operations (e.g., admin views)
- SQLAlchemy session state leaks between requests
- Fails silently if middleware doesn't run (security vulnerability)
**Do this instead:**
```python
# Explicit filtering in route handlers
@router.get("/api/projects")
def list_projects(current_user: UserContext = Depends(get_current_user), db: Session = Depends(get_db)):
return db.query(Project).filter(
(Project.owner_id == current_user.user_id) |
(Project.members.any(ProjectMember.user_id == current_user.user_id))
).all()
```
### Anti-Pattern 3: Storing Refresh Tokens in localStorage
**What people do:**
```typescript
// Frontend stores refresh token in localStorage
localStorage.setItem('refresh_token', refreshToken); // ❌ VULNERABLE TO XSS
```
**Why it's wrong:**
- XSS attacks can read localStorage and steal refresh tokens
- Refresh tokens are long-lived (30 days)—high-value targets
**Do this instead:**
```typescript
// Store refresh token in httpOnly cookie (set by backend)
// Backend response:
Set-Cookie: refresh_token=<token>; HttpOnly; Secure; SameSite=Strict
// OR store in secure storage with encryption (mobile apps)
// Access tokens in memory only (SessionStorage acceptable)
sessionStorage.setItem('access_token', accessToken); // ✅ Cleared on tab close
```
### Anti-Pattern 4: No Token Expiration Validation
**What people do:**
```python
def verify_token(token: str):
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload["sub"] # ❌ Doesn't check if user still exists/active
```
**Why it's wrong:**
- Deleted users can still authenticate (token valid until expiration)
- Deactivated accounts remain accessible
- No way to revoke access immediately
**Do this instead:**
```python
def get_current_user(credentials = Depends(security), db: Session = Depends(get_db)):
payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
user_id = payload["sub"]
user = db.query(User).filter(User.id == user_id, User.is_active == True).first() # ✅ Verify user active
if not user:
raise HTTPException(status_code=401, detail="User not found or inactive")
return UserContext(user_id=user.id, email=user.email, is_admin=user.is_admin)
```
### Anti-Pattern 5: CASCADE DELETE Without Considering Foreign Keys
**What people do:**
```python
# Delete user without considering owned projects
db.delete(user) # ❌ Fails if projects reference this user
db.commit()
```
**Why it's wrong:**
- Foreign key constraint violations
- Data loss if CASCADE propagates unexpectedly
- No clear ownership transfer
**Do this instead:**
```python
# Before deleting user, reassign or delete owned resources
def delete_user(user_id: int, db: Session):
# Transfer project ownership to another admin
admin = db.query(User).filter(User.is_admin == True, User.id != user_id).first()
if not admin:
raise HTTPException(status_code=400, detail="Cannot delete last admin")
db.execute(
update(Project)
.where(Project.owner_id == user_id)
.values(owner_id=admin.id)
)
# Now safe to delete user (SET NULL handles created_by/updated_by)
user = db.query(User).filter(User.id == user_id).first()
db.delete(user)
db.commit()
```
## Scaling Considerations
### Single-Writer Limitation (SQLite)
**Problem:** SQLite uses a single write lock. With many concurrent users editing requirements, write contention increases.
**Mitigation:**
- Enable WAL mode: `PRAGMA journal_mode=WAL;` (allows concurrent reads during writes)
- Use connection pooling with conservative pool size (avoid lock queue buildup)
- Consider read replicas for queries (projects, stats dashboards)
- At ~50 concurrent writers, plan migration to PostgreSQL
**When to migrate to PostgreSQL:**
- **User count:** >100 active users (>50 concurrent writers)
- **Data size:** >10GB database
- **Features needed:** Full-text search, advanced RBAC (Row Level Security), JSON columns
- **Cost:** PostgreSQL adds operational complexity—delay until necessary
### Token Cleanup (Garbage Collection)
**Problem:** Expired refresh tokens and invite tokens accumulate in database.
**Solution:**
```python
# Scheduled task (run daily via cron or Celery)
def cleanup_expired_tokens(db: Session):
now = datetime.now(timezone.utc)
# Delete expired refresh tokens
db.query(RefreshToken).filter(
RefreshToken.expires_at < now
).delete()
# Delete used or expired invites older than 30 days
cutoff = now - timedelta(days=30)
db.query(InviteToken).filter(
(InviteToken.used_at < cutoff) | (InviteToken.expires_at < now)
).delete()
db.commit()
```
### Query Performance (Indexing)
**Critical indexes added:**
- `users.email` (unique, login queries)
- `projects.owner_id` (filter by owner)
- `project_members(project_id, user_id)` (composite PK, bidirectional lookups)
- `requirement_nodes.project_id` (filter requirements by project)
- `refresh_tokens.token_hash` (lookup during refresh)
- `invite_tokens.email` (check existing invites)
**Monitor:** Use `EXPLAIN QUERY PLAN` to verify index usage.
## Security Considerations
### Password Storage
- **Hash algorithm:** bcrypt with cost factor 12 (via passlib)
- **Never log passwords** (sanitize logs, error messages)
- **Enforce minimum strength:** 8 characters, mixed case, numbers (frontend + backend validation)
### Token Security
- **Access tokens:** Short-lived (15min), stored in memory (SessionStorage acceptable)
- **Refresh tokens:** Long-lived (30 days), httpOnly cookies or encrypted storage
- **Token hashing:** Store only hashed refresh tokens in DB (SHA-256)
- **Secret rotation:** Support multiple SECRET_KEYs for gradual rotation
### CSRF Protection
- **Stateless JWTs:** No CSRF risk (no cookies for auth)
- **If using refresh token cookies:** Require SameSite=Strict or CSRF token
### Rate Limiting
Add rate limiting to auth endpoints:
```python
# Using slowapi (FastAPI rate limiting)
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
@router.post("/auth/login")
@limiter.limit("5/minute") # 5 login attempts per minute per IP
def login(request: Request, ...):
pass
```
## Frontend Integration Points
### Angular Changes Required
1. **Auth Service:**
- Update login() to send email + password
- Store both access_token and refresh_token
- Implement token refresh logic in HTTP interceptor
- Add getCurrentUser() to fetch user profile
2. **HTTP Interceptor:**
- Inject access_token in Authorization header
- Catch 401 errors, attempt refresh, retry request
- If refresh fails, redirect to login
3. **Route Guards:**
- Update AuthGuard to check token validity
- Add RoleGuard for permission-based route protection
- Fetch user profile on app initialization
4. **UI Components:**
- Add user profile menu (email, logout button)
- Show project role badges (owner/editor/viewer)
- Add project sharing dialog (invite members)
- Show editor attribution in history panel ("Edited by Alice")
## Sources
**Multi-Tenancy Patterns:**
- [Introduction to Multi-Tenant Design with FastAPI](https://blog.greeden.me/en/2026/03/10/introduction-to-multi-tenant-design-with-fastapi-practical-patterns-for-tenant-isolation-authorization-database-strategy-and-audit-logs/) (2026 guide, HIGH confidence)
- [GitHub Discussion: How to implement Multi tenancy in FastAPI](https://github.com/fastapi/fastapi/discussions/6056)
- [MergeBoard: Multitenancy with FastAPI, SQLAlchemy and PostgreSQL](https://mergeboard.com/blog/6-multitenancy-fastapi-sqlalchemy-postgresql/)
**Authentication & RBAC:**
- [Building a Modern User Permission Management System with FastAPI, SQLAlchemy 2.0](https://dev.to/mochafreddo/building-a-modern-user-permission-management-system-with-fastapi-sqlalchemy-and-mariadb-5fp1) (HIGH confidence)
- [FastAPI RBAC - Full Implementation Tutorial](https://www.permit.io/blog/fastapi-rbac-full-implementation-tutorial)
- [JWT in FastAPI, the Secure Way (Refresh Tokens Explained)](https://medium.com/@jagan_reddy/jwt-in-fastapi-the-secure-way-refresh-tokens-explained-f7d2d17b1d17) (Jan 2026)
**Email Verification & Invites:**
- [User Account Verification Via Email - FastAPI Beyond CRUD](https://dev.to/jod35/user-account-verification-via-email-fastapi-beyond-crud-part-18-15ib)
- [Top 5 authentication solutions for secure FastAPI apps in 2026](https://workos.com/blog/top-authentication-solutions-fastapi-2026)
**Dependency Injection:**
- [FastAPI Auth with Dependency Injection](https://www.propelauth.com/post/fastapi-auth-with-dependency-injection)
- [How to Use Dependency Injection in FastAPI](https://oneuptime.com/blog/post/2026-02-02-fastapi-dependency-injection/view) (2026)
- [Dependency Injection in FastAPI: 2026 Playbook](https://thelinuxcode.com/dependency-injection-in-fastapi-2026-playbook-for-modular-testable-apis/)
**SQLite Multi-Tenancy:**
- [Give each of your users their own SQLite database](https://turso.tech/blog/give-each-of-your-users-their-own-sqlite-database) (Database-per-tenant approach)
- [Multi-tenancy - High Performance SQLite](https://highperformancesqlite.com/watch/multi-tenancy)
**Database Migrations:**
- [How to do the migration in FastAPI](https://mdhvkothari.medium.com/how-to-do-the-migration-in-fastapi-5c53d3880f12)
- [FastAPI with Async SQLAlchemy, SQLModel, and Alembic](https://testdriven.io/blog/fastapi-sqlmodel/)
---
*Architecture research for: req-planner v2.0 Multi-Tenancy & User Accounts*
*Researched: 2026-03-18*
*Confidence: HIGH (Context7 unavailable, but official FastAPI docs + SQLAlchemy 2.0 docs + recent 2026 tutorials provide authoritative patterns)*