"""SQLAlchemy declarations.
Alembic autogenerate is wired to this Base (see alembic/env.py), so adding a
model here and running `alembic revision --autogenerate -m "..."` is the whole
workflow.
What the skeleton lays down is tenancy and nothing else:
Organization a customer - the unit everything a customer composes
belongs to
User a person (or a machine account) who signs in
Membership what one user may do in one organisation
Your own tables go below the marker at the bottom. The one rule worth keeping:
anything a customer owns carries `org_id`, so app.core.context.scope_to_org can
narrow a query to the caller's tenant in one place instead of at every call site.
"""
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Integer,
String,
UniqueConstraint,
func,
)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import deferred, relationship
from sqlalchemy.sql import expression
Base = declarative_base()
# --- tenancy ----------------------------------------------------------------
class Organization(Base):
"""A customer."""
__tablename__ = "organizations"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(120), nullable=False)
# The stable handle used in URLs and config, so renaming a customer does not
# break every reference to them.
slug = Column(String(60), nullable=False, unique=True)
created_at = Column(DateTime, nullable=False, server_default=func.now())
def to_dict(self) -> dict:
return {"id": self.id, "name": self.name, "slug": self.slug}
def __repr__(self) -> str:
return f"<Organization {self.id} {self.slug!r}>"
class User(Base):
"""A person (or a machine account) who signs in.
Separate from Organization because the two are many-to-many: a consultant
belongs to several customers, and a customer has several people. Membership
carries the relationship and the role within it.
"""
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True)
email = Column(String(254), nullable=False, unique=True)
name = Column(String(120), nullable=True)
# A stable, opaque public identifier - what an API payload names instead of
# the email address. Minted once at creation and never reused, so it survives
# the user changing address and leaks nothing if it appears in a URL.
account_id = Column(String(128), nullable=False, unique=True)
# Null means this account signs in through an external identity provider
# only. Storing no hash at all is the honest representation of that, and it
# is what makes adding OIDC later a new sign-in path rather than a migration.
#
# deferred() so the hash is not loaded by every query that touches a user.
password_hash = deferred(Column(String(255), nullable=True))
# The Google account this user may also sign in with, as Google's opaque
# subject id (the `sub` claim), or null if they never linked one. Unique, so
# one Google identity maps to one account. Google verifies the email, which
# is what makes linking it to an existing address safe.
google_sub = Column(String(255), nullable=True, unique=True)
is_active = Column(Boolean, nullable=False, server_default=expression.true(),
default=True)
created_at = Column(DateTime, nullable=False, server_default=func.now())
last_login_at = Column(DateTime, nullable=True)
# Brute-force throttling. Kept on the row rather than in process memory
# because the app scales horizontally - an in-memory counter resets every
# deploy and is invisible to the other instances.
failed_logins = Column(Integer, nullable=False, server_default="0", default=0)
locked_until = Column(DateTime, nullable=True)
memberships = relationship(
"Membership", back_populates="user", cascade="all, delete-orphan"
)
def to_dict(self) -> dict:
"""Never includes the hash. There is no view that needs it, and the
surest way to keep it out of a response is to keep it out of here."""
return {"id": self.id, "email": self.email, "name": self.name,
"account_id": self.account_id, "is_active": self.is_active}
def __repr__(self) -> str:
return f"<User {self.id} {self.email!r}>"
class Membership(Base):
"""What one user may do in one organisation.
The role here is what a Principal's scopes are derived from, so this table is
the answer to "may this caller do this" without the answer depending on which
façade the request arrived through.
"""
__tablename__ = "memberships"
__table_args__ = (
UniqueConstraint("user_id", "org_id", name="uq_membership_user_org"),
)
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"),
nullable=False, index=True)
org_id = Column(Integer, ForeignKey("organizations.id", ondelete="CASCADE"),
nullable=False, index=True)
# owner | admin | member | viewer. Mapped to scopes in app/core/auth.py
# rather than stored as scopes, so tightening what "member" means is one edit
# instead of a migration over every row.
role = Column(String(20), nullable=False, default="member")
created_at = Column(DateTime, nullable=False, server_default=func.now())
user = relationship("User", back_populates="memberships")
organization = relationship("Organization")
def __repr__(self) -> str:
return f"<Membership user={self.user_id} org={self.org_id} {self.role!r}>"
# --- your models ------------------------------------------------------------
# Carry `org_id` on anything a customer owns, and query it through
# app.core.context.scope_to_org.