feat: indie status page SaaS - initial release

This commit is contained in:
Ubuntu 2026-04-25 09:39:57 +00:00
parent ee2bc87ade
commit b7a8142ca0
14 changed files with 2703 additions and 0 deletions

155
app/api/organizations.py Normal file
View file

@ -0,0 +1,155 @@
"""Organization API endpoints: view org, list tiers, upgrade/downgrade."""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import get_current_user, get_current_org
from app.dependencies import get_db
from app.models.saas_models import Organization, OrganizationMember, User
from app.services.tier_limits import (
TIER_LIMITS,
get_org_limits,
get_tier_info,
)
router = APIRouter(tags=["organizations"])
# ── Response schemas ────────────────────────────────────────────────────────
class OrgMemberResponse(BaseModel):
user_id: str
email: str
display_name: str | None
role: str
class OrgResponse(BaseModel):
id: str
slug: str
name: str
tier: str
custom_domain: str | None
member_count: int
tier_info: dict
class TierDetailResponse(BaseModel):
tier: str
limits: dict
class UpgradeRequest(BaseModel):
tier: str # "free" | "pro" | "team"
# ── Endpoints ───────────────────────────────────────────────────────────────
@router.get("/tiers")
async def list_tiers():
"""List all available tiers and their limits (public endpoint)."""
return {
"tiers": [
{
"name": tier_name,
"display_name": {
"free": "Free",
"pro": "Pro ($9/mo)",
"team": "Team ($29/mo)",
}.get(tier_name, tier_name),
"limits": limits,
}
for tier_name, limits in TIER_LIMITS.items()
]
}
@router.get("/my", response_model=OrgResponse)
async def get_my_org(
org: Organization = Depends(get_current_org),
db: AsyncSession = Depends(get_db),
):
"""Get the current user's organization with tier limits info."""
# Count members
result = await db.execute(
select(OrganizationMember).where(
OrganizationMember.organization_id == org.id
)
)
members = result.scalars().all()
return OrgResponse(
id=org.id,
slug=org.slug,
name=org.name,
tier=org.tier or "free",
custom_domain=org.custom_domain,
member_count=len(members),
tier_info=get_tier_info(org),
)
@router.patch("/my/tier", response_model=OrgResponse)
async def update_org_tier(
body: UpgradeRequest,
org: Organization = Depends(get_current_org),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Update the organization's tier.
In production, this would be gated by Stripe payment verification.
For now, this is an admin-only endpoint that directly sets the tier.
Only the org owner can change the tier.
"""
# Verify user is owner
result = await db.execute(
select(OrganizationMember).where(
OrganizationMember.organization_id == org.id,
OrganizationMember.user_id == user.id,
)
)
membership = result.scalar_one_or_none()
if not membership or membership.role != "owner":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the organization owner can change the plan tier.",
)
if body.tier not in ("free", "pro", "team"):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid tier '{body.tier}'. Must be one of: free, pro, team.",
)
org.tier = body.tier
await db.flush()
await db.refresh(org)
# Count members for response
members_result = await db.execute(
select(OrganizationMember).where(
OrganizationMember.organization_id == org.id
)
)
members = members_result.scalars().all()
return OrgResponse(
id=org.id,
slug=org.slug,
name=org.name,
tier=org.tier,
custom_domain=org.custom_domain,
member_count=len(members),
tier_info=get_tier_info(org),
)
@router.get("/my/limits")
async def get_my_limits(
org: Organization = Depends(get_current_org),
):
"""Get the current organization's tier limits and feature flags."""
return get_tier_info(org)

View file

@ -5,9 +5,13 @@ from app.api.incidents import router as incidents_router
from app.api.monitors import router as monitors_router
from app.api.subscribers import router as subscribers_router
from app.api.settings import router as settings_router
from app.api.organizations import router as organizations_router
from app.routes.auth import router as auth_router
api_v1_router = APIRouter()
api_v1_router.include_router(auth_router, tags=["auth"])
api_v1_router.include_router(organizations_router, prefix="/organizations", tags=["organizations"])
api_v1_router.include_router(services_router, prefix="/services", tags=["services"])
api_v1_router.include_router(incidents_router, prefix="/incidents", tags=["incidents"])
api_v1_router.include_router(monitors_router, prefix="/monitors", tags=["monitors"])

99
app/auth.py Normal file
View file

@ -0,0 +1,99 @@
"""JWT authentication and password hashing utilities."""
from datetime import datetime, timedelta
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.dependencies import get_db
from app.models.saas_models import User, Organization, OrganizationMember
# Password hashing context
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# OAuth2 scheme for token extraction
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
def hash_password(password: str) -> str:
"""Hash a password using bcrypt."""
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against a hash."""
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(user_id: str, exp_hours: int = 72) -> str:
"""Create a JWT access token for the given user ID."""
payload = {
"sub": user_id,
"exp": datetime.utcnow() + timedelta(hours=exp_hours),
}
return jwt.encode(payload, settings.secret_key, algorithm="HS256")
def decode_access_token(token: str) -> dict:
"""Decode and verify a JWT access token. Raises JWTError on failure."""
return jwt.decode(token, settings.secret_key, algorithms=["HS256"])
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db),
) -> User:
"""FastAPI dependency: extract and validate current user from JWT."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_access_token(token)
user_id: str | None = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise credentials_exception
return user
async def get_current_org(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Organization:
"""FastAPI dependency: get the user's first organization (simplified).
In the future, this will support org-switching via header or session.
"""
result = await db.execute(
select(OrganizationMember).where(OrganizationMember.user_id == user.id)
)
membership = result.scalars().first()
if not membership:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User is not a member of any organization",
)
org_result = await db.execute(
select(Organization).where(Organization.id == membership.organization_id)
)
org = org_result.scalar_one_or_none()
if org is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Organization not found",
)
return org

View file

@ -8,6 +8,12 @@ from app.models.models import (
NotificationLog,
SiteSetting,
)
from app.models.saas_models import (
User,
Organization,
OrganizationMember,
StatusPage,
)
__all__ = [
"Service",
@ -18,4 +24,8 @@ __all__ = [
"Subscriber",
"NotificationLog",
"SiteSetting",
"User",
"Organization",
"OrganizationMember",
"StatusPage",
]

View file

@ -15,6 +15,9 @@ class Service(Base):
__tablename__ = "services"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid_str)
organization_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("organizations.id"), nullable=True, index=True
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
slug: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
@ -74,6 +77,9 @@ class Monitor(Base):
__tablename__ = "monitors"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid_str)
organization_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("organizations.id"), nullable=True, index=True
)
service_id: Mapped[str] = mapped_column(
String(36), ForeignKey("services.id"), nullable=False, index=True
)
@ -114,6 +120,9 @@ class Subscriber(Base):
__tablename__ = "subscribers"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid_str)
organization_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("organizations.id"), nullable=True, index=True
)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
is_confirmed: Mapped[bool] = mapped_column(Boolean, default=False)
confirm_token: Mapped[str | None] = mapped_column(String(100), nullable=True)

113
app/models/saas_models.py Normal file
View file

@ -0,0 +1,113 @@
"""SaaS multi-tenancy models: User, Organization, OrganizationMember, StatusPage."""
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def _uuid_str() -> str:
return str(uuid.uuid4())
class User(Base):
"""Individual who can log in."""
__tablename__ = "users"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid_str)
email: Mapped[str] = mapped_column(
String(255), unique=True, nullable=False, index=True
)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
display_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
is_email_verified: Mapped[bool] = mapped_column(Boolean, default=False)
email_verify_token: Mapped[str | None] = mapped_column(String(100), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
memberships: Mapped[list["OrganizationMember"]] = relationship(
back_populates="user"
)
class Organization(Base):
"""The tenant; owns status pages."""
__tablename__ = "organizations"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid_str)
slug: Mapped[str] = mapped_column(
String(50), unique=True, nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
tier: Mapped[str] = mapped_column(String(20), nullable=False, default="free")
# "free" | "pro" | "team"
stripe_customer_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
custom_domain: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
members: Mapped[list["OrganizationMember"]] = relationship(
back_populates="organization"
)
status_pages: Mapped[list["StatusPage"]] = relationship(
back_populates="organization"
)
# Services linked to this org (from app.models.models.Service)
class OrganizationMember(Base):
"""Joins users to orgs with roles."""
__tablename__ = "organization_members"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid_str)
organization_id: Mapped[str] = mapped_column(
String(36), ForeignKey("organizations.id"), nullable=False, index=True
)
user_id: Mapped[str] = mapped_column(
String(36), ForeignKey("users.id"), nullable=False, index=True
)
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member")
# "owner" | "admin" | "member"
joined_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
organization: Mapped["Organization"] = relationship(back_populates="members")
user: Mapped["User"] = relationship(back_populates="memberships")
__table_args__ = (
{"sqlite_autoincrement": True},
)
class StatusPage(Base):
"""Per-organization status page."""
__tablename__ = "status_pages"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid_str)
organization_id: Mapped[str] = mapped_column(
String(36), ForeignKey("organizations.id"), nullable=False, index=True
)
slug: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(100), nullable=False)
subdomain: Mapped[str | None] = mapped_column(String(100), nullable=True)
custom_domain: Mapped[str | None] = mapped_column(String(255), nullable=True)
is_public: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
organization: Mapped["Organization"] = relationship(back_populates="status_pages")
__table_args__ = (
UniqueConstraint("organization_id", "slug", name="uq_status_page_org_slug"),
)

0
app/routes/__init__.py Normal file
View file

122
app/routes/auth.py Normal file
View file

@ -0,0 +1,122 @@
"""Auth routes: register, login, and current user profile."""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, EmailStr
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import create_access_token, get_current_user, hash_password, verify_password
from app.dependencies import get_db
from app.models.saas_models import Organization, OrganizationMember, StatusPage, User
router = APIRouter(tags=["auth"])
# ── Request / Response schemas ──────────────────────────────────────────────
class RegisterRequest(BaseModel):
email: EmailStr
password: str
class LoginRequest(BaseModel):
email: EmailStr
password: str
class AuthResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class UserProfile(BaseModel):
id: str
email: str
display_name: str | None = None
is_email_verified: bool
created_at: str | None = None
# ── Routes ───────────────────────────────────────────────────────────────────
@router.post("/auth/register", status_code=status.HTTP_201_CREATED, response_model=AuthResponse)
async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)):
"""Register a new user, create a default Organization + StatusPage, return JWT."""
# Check for existing user
result = await db.execute(select(User).where(User.email == body.email))
if result.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A user with this email already exists",
)
# Create user
user = User(
email=body.email,
password_hash=hash_password(body.password),
)
db.add(user)
await db.flush() # assign user.id
# Create default organization
org_slug = body.email.split("@")[0].lower()
org = Organization(
name=org_slug,
slug=org_slug,
)
db.add(org)
await db.flush() # assign org.id
# Create organization membership (owner)
membership = OrganizationMember(
organization_id=org.id,
user_id=user.id,
role="owner",
)
db.add(membership)
# Create default status page
status_page = StatusPage(
organization_id=org.id,
slug="main",
title="Status Page",
)
db.add(status_page)
# Commit all together (the get_db dependency also commits, but flush ensures
# relationships are consistent before we return)
await db.flush()
# Generate JWT
token = create_access_token(user.id)
return AuthResponse(access_token=token)
@router.post("/auth/login", response_model=AuthResponse)
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
"""Authenticate user with email + password, return JWT."""
result = await db.execute(select(User).where(User.email == body.email))
user = result.scalar_one_or_none()
if user is None or not verify_password(body.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
token = create_access_token(user.id)
return AuthResponse(access_token=token)
@router.get("/auth/me", response_model=UserProfile)
async def me(current_user: User = Depends(get_current_user)):
"""Return the current authenticated user's profile."""
return UserProfile(
id=current_user.id,
email=current_user.email,
display_name=current_user.display_name,
is_email_verified=current_user.is_email_verified,
created_at=current_user.created_at.isoformat() if current_user.created_at else None,
)

229
app/services/tier_limits.py Normal file
View file

@ -0,0 +1,229 @@
"""Tier enforcement: limits and feature flags for Free/Pro/Team plans.
This module defines the per-tier limits and provides enforcement functions
that raise HTTP 403 when a limit is exceeded or a feature is unavailable.
"""
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.saas_models import Organization, OrganizationMember, StatusPage
from app.models.models import Service, Monitor, Subscriber
# ── Tier limits configuration ──────────────────────────────────────────────
TIER_LIMITS: dict[str, dict[str, int | bool]] = {
"free": {
"status_pages": 1,
"services_per_page": 5,
"monitors_per_service": 1,
"subscribers": 25,
"members": 1,
"check_interval_min": 5, # minutes
"custom_domain": False,
"custom_branding": False,
"webhooks": False,
"api_access": False,
"incident_history_days": 30,
"sla_badge": False,
"password_protection": False,
},
"pro": {
"status_pages": 5,
"services_per_page": 50,
"monitors_per_service": 5,
"subscribers": 500,
"members": 3,
"check_interval_min": 1, # minutes
"custom_domain": True,
"custom_branding": True,
"webhooks": True,
"api_access": True,
"incident_history_days": 365,
"sla_badge": True,
"password_protection": False,
},
"team": {
"status_pages": -1, # unlimited
"services_per_page": -1,
"monitors_per_service": -1,
"subscribers": -1,
"members": -1,
"check_interval_min": 0, # 30 seconds (0 min)
"custom_domain": True,
"custom_branding": True,
"webhooks": True,
"api_access": True,
"incident_history_days": -1, # unlimited
"sla_badge": True,
"password_protection": True,
},
}
# ── Tier info helpers ───────────────────────────────────────────────────────
def get_tier_limits(tier: str) -> dict:
"""Return the limits dict for a given tier name. Falls back to free."""
return TIER_LIMITS.get(tier, TIER_LIMITS["free"])
def get_org_limits(org: Organization) -> dict:
"""Return the limits dict for an organization based on its tier."""
return get_tier_limits(org.tier or "free")
def get_limit(org: Organization, feature: str):
"""Return the limit value for a specific feature given the org's tier.
Returns:
int: numeric limit (-1 means unlimited)
bool: feature flag (True/False)
None: unknown feature
"""
limits = get_org_limits(org)
return limits.get(feature)
# ── Enforcement ─────────────────────────────────────────────────────────────
class TierLimitExceeded(HTTPException):
"""Raised when a tier limit is exceeded."""
def __init__(self, feature: str, limit: int | bool):
if limit is False:
detail = f"Feature '{feature}' is not available on your current plan. Upgrade to access it."
else:
detail = (
f"Tier limit reached for '{feature}' ({limit}). "
"Upgrade your plan to increase this limit."
)
super().__init__(status_code=status.HTTP_403_FORBIDDEN, detail=detail)
async def enforce_limit(
db: AsyncSession,
org: Organization,
feature: str,
current_count: int,
) -> None:
"""Raise TierLimitExceeded if the current count meets or exceeds the tier limit.
Args:
db: Database session for queries.
org: The organization whose tier limits to check.
feature: The feature name (key in TIER_LIMITS).
current_count: How many of this feature the org currently has.
Raises:
TierLimitExceeded: If the limit is reached or the feature is disabled.
"""
limit = get_limit(org, feature)
if limit is None:
return # Unknown feature — don't block
if limit is False:
# Feature flag: not available on this tier
raise TierLimitExceeded(feature, limit)
if limit == -1:
return # Unlimited
if isinstance(limit, int) and current_count >= limit:
raise TierLimitExceeded(feature, limit)
# ── Concrete enforcement helpers ────────────────────────────────────────────
async def check_status_page_limit(db: AsyncSession, org: Organization) -> None:
"""Check that the org hasn't exceeded its status page limit."""
result = await db.execute(
select(func.count(StatusPage.id)).where(
StatusPage.organization_id == org.id
)
)
count = result.scalar() or 0
await enforce_limit(db, org, "status_pages", count)
async def check_service_limit(
db: AsyncSession, org: Organization, status_page_id: str | None = None
) -> None:
"""Check that the org hasn't exceeded its services-per-page limit.
If status_page_id is None, counts all services for the org.
"""
query = select(func.count(Service.id)).where(
Service.organization_id == org.id
)
if status_page_id:
# In future, Service will have a status_page_id column
# For now, count all services in the org
pass
result = await db.execute(query)
count = result.scalar() or 0
await enforce_limit(db, org, "services_per_page", count)
async def check_monitor_limit(
db: AsyncSession, org: Organization, service_id: str
) -> None:
"""Check that the service hasn't exceeded its monitors-per-service limit."""
result = await db.execute(
select(func.count(Monitor.id)).where(Monitor.service_id == service_id)
)
count = result.scalar() or 0
await enforce_limit(db, org, "monitors_per_service", count)
async def check_subscriber_limit(db: AsyncSession, org: Organization) -> None:
"""Check that the org hasn't exceeded its subscriber limit."""
result = await db.execute(
select(func.count(Subscriber.id)).where(
Subscriber.organization_id == org.id
)
)
count = result.scalar() or 0
await enforce_limit(db, org, "subscribers", count)
async def check_member_limit(db: AsyncSession, org: Organization) -> None:
"""Check that the org hasn't exceeded its team member limit."""
result = await db.execute(
select(func.count(OrganizationMember.id)).where(
OrganizationMember.organization_id == org.id
)
)
count = result.scalar() or 0
await enforce_limit(db, org, "members", count)
def enforce_feature(org: Organization, feature: str) -> None:
"""Enforce a boolean feature flag. Raises TierLimitExceeded if False.
Use this for features that are either allowed or not (e.g., custom_domain,
webhooks, api_access) without a numeric limit.
"""
limit = get_limit(org, feature)
if limit is False:
raise TierLimitExceeded(feature, False)
if limit is None:
# Unknown feature — don't block
return
def get_tier_info(org: Organization) -> dict:
"""Return a dict of the org's current tier with limits and feature flags.
Useful for API responses that show the org what they can and can't do.
"""
limits = get_org_limits(org)
return {
"tier": org.tier or "free",
"limits": limits,
"organization_id": org.id,
"organization_slug": org.slug,
}