fix: code cleanup

This commit is contained in:
Furqan-14
2026-08-31 20:39:41 -04:00
parent b923b3ed15
commit 6cd665e95a
199 changed files with 61 additions and 2536 deletions
-24
View File
@@ -8,31 +8,21 @@ from sqlalchemy import pool
from alembic import context
# Add parent directory to path to import app modules
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
# Load environment variables before importing app
from dotenv import load_dotenv
app_env = os.getenv("APP_ENV", "local")
env_filename = f".env.{app_env}"
# Define paths
base_path = Path(__file__).resolve().parent.parent
backend_path = base_path
# What the process was actually started with. The .env.* files below load with
# override=True, which was also beating the real environment — so
# `DATABASE_URL=... alembic upgrade head` silently migrated whatever the file
# named instead. Restored afterwards so an explicit variable wins, which is how
# every CI runner and container expects to point a tool at a database.
_explicit = dict(os.environ)
# Load environment variables
load_dotenv(dotenv_path=base_path / '.env')
load_dotenv(dotenv_path=backend_path / '.env')
# Override with specific environment config
if (base_path / env_filename).exists():
load_dotenv(dotenv_path=base_path / env_filename, override=True)
if (backend_path / env_filename).exists():
@@ -40,11 +30,9 @@ if (backend_path / env_filename).exists():
os.environ.update(_explicit)
# Import app settings and database
from app.config.settings import settings
from app.config.database import Base
# Import all models for autogenerate support
import app.models.auth.user_model
import app.models.auth.role_model
import app.models.auth.tenant_model
@@ -53,27 +41,15 @@ import app.models.auth.role_access_model
import app.models.theme.color_palette_model
import app.models.auth.subscription_plan_model
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Set the database URL from app settings
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
@@ -11,7 +11,6 @@ from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '03a1b1f05e99'
down_revision: Union[str, Sequence[str], None] = 'cd8ba77ffd9e'
branch_labels: Union[str, Sequence[str], None] = None
@@ -20,7 +19,6 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('event_logs',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('event_id', sa.UUID(), nullable=False),
@@ -40,14 +38,11 @@ def upgrade() -> None:
op.create_index(op.f('ix_event_logs_event_id'), 'event_logs', ['event_id'], unique=False)
op.create_index(op.f('ix_event_logs_next_retry_at'), 'event_logs', ['next_retry_at'], unique=False)
op.create_index(op.f('ix_event_logs_status'), 'event_logs', ['status'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_event_logs_status'), table_name='event_logs')
op.drop_index(op.f('ix_event_logs_next_retry_at'), table_name='event_logs')
op.drop_index(op.f('ix_event_logs_event_id'), table_name='event_logs')
op.drop_table('event_logs')
# ### end Alembic commands ###
@@ -10,7 +10,6 @@ from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "5f2e9c1a7b44"
down_revision: Union[str, Sequence[str], None] = "720027c97104"
branch_labels: Union[str, Sequence[str], None] = None
@@ -11,7 +11,6 @@ from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '63b95ea5b967'
down_revision: Union[str, Sequence[str], None] = '88cfc7dee19d'
branch_labels: Union[str, Sequence[str], None] = None
@@ -20,7 +19,6 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('module_accesses',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('module_id', sa.UUID(), nullable=False),
@@ -47,12 +45,10 @@ def upgrade() -> None:
op.drop_constraint(op.f('accesses_module_id_fkey'), 'accesses', type_='foreignkey')
op.drop_column('accesses', 'module_id')
op.drop_column('accesses', 'scope')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('accesses', sa.Column('scope', sa.VARCHAR(), autoincrement=False, nullable=False))
op.add_column('accesses', sa.Column('module_id', sa.UUID(), autoincrement=False, nullable=True))
op.create_foreign_key(op.f('accesses_module_id_fkey'), 'accesses', 'modules', ['module_id'], ['id'])
@@ -67,4 +63,3 @@ def downgrade() -> None:
op.drop_index(op.f('ix_module_accesses_category'), table_name='module_accesses')
op.drop_index(op.f('ix_module_accesses_access_code'), table_name='module_accesses')
op.drop_table('module_accesses')
# ### end Alembic commands ###
@@ -10,7 +10,6 @@ from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "6a1b2c3d4e55"
down_revision: Union[str, Sequence[str], None] = "5f2e9c1a7b44"
branch_labels: Union[str, Sequence[str], None] = None
@@ -11,7 +11,6 @@ from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '720027c97104'
down_revision: Union[str, Sequence[str], None] = '9283c3f52a76'
branch_labels: Union[str, Sequence[str], None] = None
@@ -20,13 +19,9 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('event_logs', sa.Column('follow_up_event', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('event_logs', 'follow_up_event')
# ### end Alembic commands ###
@@ -11,7 +11,6 @@ from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '73b754d5b2c5'
down_revision: Union[str, Sequence[str], None] = 'c37ba6143f83'
branch_labels: Union[str, Sequence[str], None] = None
@@ -20,7 +19,6 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('audit_logs',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('module_name', sa.String(length=100), nullable=False),
@@ -31,13 +29,10 @@ def upgrade() -> None:
)
op.create_index(op.f('ix_audit_logs_id'), 'audit_logs', ['id'], unique=False)
op.create_index(op.f('ix_audit_logs_module_name'), 'audit_logs', ['module_name'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_audit_logs_module_name'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_id'), table_name='audit_logs')
op.drop_table('audit_logs')
# ### end Alembic commands ###
@@ -12,7 +12,6 @@ from app.core.migration_helpers import drop_foreign_key_on
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '74b6ccfaee8e'
down_revision: Union[str, Sequence[str], None] = '8acd83604252'
branch_labels: Union[str, Sequence[str], None] = None
@@ -21,7 +20,6 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('modules',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('module_id', sa.String(), nullable=False),
@@ -99,40 +97,32 @@ def upgrade() -> None:
op.create_index(op.f('ix_sso_grants_tenant_id'), 'sso_grants', ['tenant_id'], unique=False)
op.create_index(op.f('ix_sso_grants_user_id'), 'sso_grants', ['user_id'], unique=False)
# Add scope column as nullable first
op.add_column('accesses', sa.Column('scope', sa.String(), nullable=True))
op.add_column('accesses', sa.Column('module_id', sa.UUID(), nullable=True))
op.add_column('accesses', sa.Column('sync_checksum', sa.String(), nullable=True))
op.add_column('accesses', sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=True))
# Update existing rows with default scope
op.execute("UPDATE accesses SET scope = 'saas' WHERE scope IS NULL")
# Now make it not null
op.alter_column('accesses', 'scope', nullable=False)
op.create_index(op.f('ix_accesses_module_id'), 'accesses', ['module_id'], unique=False)
op.create_index(op.f('ix_accesses_scope'), 'accesses', ['scope'], unique=False)
op.create_foreign_key(None, 'accesses', 'modules', ['module_id'], ['id'])
# Inspect to see if constraint/column exists to avoid transaction abortion on failure
bind = op.get_bind()
inspector = sa.inspect(bind)
# Check and drop foreign key
fks = inspector.get_foreign_keys('users')
if any(fk['name'] == 'users_palette_id_fkey' for fk in fks):
op.drop_constraint('users_palette_id_fkey', 'users', type_='foreignkey')
# Check and drop column
columns = [c['name'] for c in inspector.get_columns('users')]
if 'palette_id' in columns:
op.drop_column('users', 'palette_id')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('palette_id', sa.UUID(), autoincrement=False, nullable=True))
op.create_foreign_key(op.f('users_palette_id_fkey'), 'users', 'color_palettes', ['palette_id'], ['id'])
drop_foreign_key_on("accesses", "module_id")
@@ -155,4 +145,3 @@ def downgrade() -> None:
op.drop_table('module_environments')
op.drop_index(op.f('ix_modules_module_id'), table_name='modules')
op.drop_table('modules')
# ### end Alembic commands ###
@@ -10,7 +10,6 @@ from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "7b2c4d5e6f77"
down_revision: Union[str, Sequence[str], None] = "6a1b2c3d4e55"
branch_labels: Union[str, Sequence[str], None] = None
@@ -11,7 +11,6 @@ from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '88cfc7dee19d'
down_revision: Union[str, Sequence[str], None] = '03a1b1f05e99'
branch_labels: Union[str, Sequence[str], None] = None
@@ -20,19 +19,15 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_accesses_access_code'), table_name='accesses')
op.create_index(op.f('ix_accesses_access_code'), 'accesses', ['access_code'], unique=False)
op.create_index('ix_access_code_module', 'accesses', ['access_code', 'module_id'], unique=True, postgresql_where=sa.text('module_id IS NOT NULL'))
op.create_index('ix_access_code_saas', 'accesses', ['access_code'], unique=True, postgresql_where=sa.text('module_id IS NULL'))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_access_code_saas', table_name='accesses', postgresql_where=sa.text('module_id IS NULL'))
op.drop_index('ix_access_code_module', table_name='accesses', postgresql_where=sa.text('module_id IS NOT NULL'))
op.drop_index(op.f('ix_accesses_access_code'), table_name='accesses')
op.create_index(op.f('ix_accesses_access_code'), 'accesses', ['access_code'], unique=True)
# ### end Alembic commands ###
@@ -11,7 +11,6 @@ from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '8acd83604252'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
@@ -20,7 +19,6 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('accesses',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('access_code', sa.String(), nullable=False),
@@ -102,12 +100,10 @@ def upgrade() -> None:
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_index(op.f('ix_users_role_id'), 'users', ['role_id'], unique=False)
op.create_index(op.f('ix_users_tenant_id'), 'users', ['tenant_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_users_tenant_id'), table_name='users')
op.drop_index(op.f('ix_users_role_id'), table_name='users')
op.drop_index(op.f('ix_users_id'), table_name='users')
@@ -129,4 +125,3 @@ def downgrade() -> None:
op.drop_index(op.f('ix_accesses_category'), table_name='accesses')
op.drop_index(op.f('ix_accesses_access_code'), table_name='accesses')
op.drop_table('accesses')
# ### end Alembic commands ###
@@ -12,7 +12,6 @@ from app.core.migration_helpers import drop_foreign_key_on
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '91cc93992a91'
down_revision: Union[str, Sequence[str], None] = '63b95ea5b967'
branch_labels: Union[str, Sequence[str], None] = None
@@ -21,7 +20,6 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('role_module_accesses',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('role_id', sa.UUID(), nullable=False),
@@ -37,16 +35,13 @@ def upgrade() -> None:
op.add_column('module_accesses', sa.Column('parent_id', sa.UUID(), nullable=True))
op.create_index(op.f('ix_module_accesses_parent_id'), 'module_accesses', ['parent_id'], unique=False)
op.create_foreign_key(None, 'module_accesses', 'module_accesses', ['parent_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
drop_foreign_key_on("module_accesses", "parent_id")
op.drop_index(op.f('ix_module_accesses_parent_id'), table_name='module_accesses')
op.drop_column('module_accesses', 'parent_id')
op.drop_index(op.f('ix_role_module_accesses_role_id'), table_name='role_module_accesses')
op.drop_index(op.f('ix_role_module_accesses_module_access_id'), table_name='role_module_accesses')
op.drop_table('role_module_accesses')
# ### end Alembic commands ###
@@ -12,7 +12,6 @@ from app.core.migration_helpers import drop_foreign_key_on
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '9283c3f52a76'
down_revision: Union[str, Sequence[str], None] = 'f9cf173f48f9'
branch_labels: Union[str, Sequence[str], None] = None
@@ -21,7 +20,6 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('subscription_plans',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
@@ -64,12 +62,10 @@ def upgrade() -> None:
op.add_column('tenants', sa.Column('plan_id', sa.UUID(), nullable=True))
op.create_index(op.f('ix_tenants_plan_id'), 'tenants', ['plan_id'], unique=False)
op.create_foreign_key(None, 'tenants', 'subscription_plans', ['plan_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
drop_foreign_key_on("tenants", "plan_id")
op.drop_index(op.f('ix_tenants_plan_id'), table_name='tenants')
op.drop_column('tenants', 'plan_id')
@@ -84,4 +80,3 @@ def downgrade() -> None:
op.drop_index(op.f('ix_subscription_plans_name'), table_name='subscription_plans')
op.drop_index(op.f('ix_subscription_plans_id'), table_name='subscription_plans')
op.drop_table('subscription_plans')
# ### end Alembic commands ###
@@ -45,9 +45,6 @@ def upgrade() -> None:
server_default=sa.text("gen_random_uuid()")),
sa.Column("tenant_id", UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
# CASCADE: an allocation to a unit that no longer exists is not a
# constraint on anything, and `org_unit_service.delete` already refuses
# to remove a unit that still has members.
sa.Column("org_unit_id", UUID(as_uuid=True),
sa.ForeignKey("org_units.id", ondelete="CASCADE"), nullable=False),
sa.Column("seat_limit", sa.Integer(), nullable=False),
@@ -55,8 +52,6 @@ def upgrade() -> None:
server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now(), onupdate=sa.func.now()),
# One per unit. Two would make "how many seats does this branch have" a
# question with two answers.
sa.UniqueConstraint("org_unit_id", name="uq_seat_allocation_unit"),
)
op.create_index("ix_seat_allocations_tenant", "org_unit_seat_allocations",
@@ -37,9 +37,6 @@ def upgrade() -> None:
),
)
# Only tenant-less accounts holding the platform superadmin role are promoted.
# A tenant-less account with any other role, or no role, is NOT a superadmin —
# it is an artefact of the signup defect and must be reviewed by hand.
op.execute(
"""
UPDATE users
@@ -52,16 +52,9 @@ def upgrade() -> None:
sa.Column("tenant_id", UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(160), nullable=False),
# A short stable handle for imports and integrations, so a customer's
# HR export can address a unit without knowing our ids.
sa.Column("code", sa.String(60), nullable=True),
# RESTRICT, not CASCADE. Deleting a department must not silently take
# every team under it — the parent's delete is refused while children
# exist, and whoever is reorganising has to say what happens to them.
sa.Column("parent_id", UUID(as_uuid=True),
sa.ForeignKey("org_units.id", ondelete="RESTRICT"), nullable=True),
# Materialised ancestry, '/id/id/'. Answers "everything under this unit"
# with one indexed prefix match instead of a recursive scan per request.
sa.Column("path", sa.Text(), nullable=False, server_default="/"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
@@ -70,8 +63,6 @@ def upgrade() -> None:
)
op.create_index("ix_org_units_tenant", "org_units", ["tenant_id"])
op.create_index("ix_org_units_parent", "org_units", ["parent_id"])
# `text_pattern_ops` so a `LIKE 'prefix%'` uses it. The default collation's
# B-tree does not serve a prefix match, which is the only query this has.
op.execute(
"CREATE INDEX ix_org_units_path ON org_units (path text_pattern_ops)"
)
@@ -86,12 +77,7 @@ def upgrade() -> None:
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("org_unit_id", UUID(as_uuid=True),
sa.ForeignKey("org_units.id", ondelete="CASCADE"), nullable=False),
# Somebody can belong to several units — a person split across two
# branches is ordinary — but exactly one is primary, which is the one
# shown beside their name and used when a single answer is needed.
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default=sa.false()),
# Separate from membership: a lead administers the unit, a member is in
# it, and the two are frequently different people.
sa.Column("is_lead", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
@@ -99,8 +85,6 @@ def upgrade() -> None:
)
op.create_index("ix_user_org_units_user", "user_org_units", ["user_id"])
op.create_index("ix_user_org_units_unit", "user_org_units", ["org_unit_id"])
# One primary each. A partial unique index rather than a check, because the
# rule is about the set of a person's rows, not about any one of them.
op.execute(
"CREATE UNIQUE INDEX uq_user_primary_org_unit ON user_org_units (user_id) "
"WHERE is_primary"
@@ -43,10 +43,7 @@ def upgrade() -> None:
"tenant_id", UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False,
),
# expiring_soon | grace_started | expired
sa.Column("kind", sa.String(30), nullable=False),
# The end date this notice was about. Nullable because a workspace with
# no end date can still be told it was suspended.
sa.Column("for_end_date", sa.Date(), nullable=True),
sa.Column("sent_to", sa.String(255), nullable=True),
sa.Column(
@@ -55,10 +52,6 @@ def upgrade() -> None:
),
)
# The idempotency key, as a constraint rather than a convention. A worker
# that runs twice, or two workers racing, cannot send the same notice twice
# — the second insert is refused by the database rather than by a check that
# someone has to remember to write.
op.create_index(
"uq_subscription_notice_once",
"subscription_notices",
-15
View File
@@ -45,36 +45,21 @@ def upgrade() -> None:
server_default=sa.text("gen_random_uuid()")),
sa.Column("tenant_id", UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
# CASCADE, unlike the invitation's inviter. A key that outlived its owner
# would be a credential with no one accountable for it and no way to
# decide what it is still allowed to do.
sa.Column("user_id", UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(120), nullable=False),
# The visible half. Shown in listings and safe to put in a log, so a key
# found somewhere it should not be can be identified and revoked without
# the finder holding a working credential.
sa.Column("prefix", sa.String(16), nullable=False),
sa.Column("key_hash", sa.String(64), nullable=False),
# A subset of what the issuing user can do. Empty means "everything the
# owner can do", which is the honest default for a first key and is
# exactly what a customer would otherwise achieve by pasting a password.
sa.Column("scopes", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
# Written at most once a minute rather than on every call: the useful
# question is "is this key still in use", and answering it exactly would
# turn every read request into a write.
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
# Global and unique: the lookup on an incoming request has no workspace
# context, because the key is the only thing the caller sends.
sa.UniqueConstraint("prefix", name="uq_api_keys_prefix"),
)
op.create_index("ix_api_keys_tenant", "api_keys", ["tenant_id"])
op.create_index("ix_api_keys_user", "api_keys", ["user_id"])
# Partial, because the only lookup that matters is of a live key.
op.create_index(
"ix_api_keys_live", "api_keys", ["prefix"],
postgresql_where=sa.text("revoked_at IS NULL"),
@@ -47,8 +47,6 @@ def upgrade() -> None:
nullable=True))
op.add_column(table, sa.Column("deleted_by_id", UUID(as_uuid=True),
nullable=True))
# Partial: almost every row is NULL, and the only question asked of this
# column is "which ones are gone".
op.create_index(
f"ix_{table}_deleted_at", table, ["deleted_at"],
postgresql_where=sa.text("deleted_at IS NOT NULL"),
@@ -44,22 +44,15 @@ def upgrade() -> None:
"tenant_email_settings",
sa.Column("id", UUID(as_uuid=True), primary_key=True,
server_default=sa.text("gen_random_uuid()")),
# One per workspace. Two would make "which account are we sending from"
# a question with no answer.
sa.Column("tenant_id", UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
sa.Column("smtp_host", sa.String(255), nullable=False),
sa.Column("smtp_port", sa.Integer(), nullable=False, server_default="587"),
sa.Column("smtp_user", sa.String(255), nullable=True),
# Fernet, not a hash: the platform has to present this credential to
# somebody else's server, so it must be recoverable.
sa.Column("smtp_password_enc", sa.Text(), nullable=True),
sa.Column("use_ssl", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("from_address", sa.String(255), nullable=False),
sa.Column("from_name", sa.String(150), nullable=True),
# Off until a test message has actually been delivered. A workspace that
# saves a typo and immediately stops receiving invitations has no way to
# tell what changed.
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("last_verified_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error", sa.String(500), nullable=True),
@@ -31,8 +31,6 @@ def upgrade() -> None:
"id", UUID(as_uuid=True), primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
# One per condition, not per occurrence: "the outbox is stuck" is one
# situation however many events are in it.
sa.Column("alert_key", sa.String(60), nullable=False, unique=True),
sa.Column("severity", sa.String(20), nullable=False),
sa.Column("detail", sa.Text(), nullable=True),
@@ -45,8 +43,6 @@ def upgrade() -> None:
sa.Column("notify_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
)
# Platform-wide operational state, deliberately not tenant-scoped: a stuck
# outbox belongs to the platform, not to any one workspace.
op.create_index("ix_alert_state_open", "alert_state", ["resolved_at"])
@@ -49,19 +49,10 @@ def upgrade() -> None:
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
sa.Column("url", sa.String(2048), nullable=False),
sa.Column("description", sa.String(255), nullable=True),
# Empty means every event. Explicit is better, but a workspace setting
# this up for the first time should not have to enumerate the catalogue
# before it can see anything arrive.
sa.Column("event_types", JSONB(), nullable=False,
server_default=sa.text("'[]'::jsonb")),
# Fernet-encrypted rather than hashed, unlike an API key: the customer
# has to configure this same value in their receiver to verify
# signatures, so the platform genuinely needs to hand it back.
sa.Column("secret_enc", sa.Text(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
# Set when the platform disables an endpoint itself, so the reason is on
# the record rather than only in a log. A customer who finds their
# webhook off deserves to know it was us and why.
sa.Column("disabled_reason", sa.String(255), nullable=True),
sa.Column("consecutive_failures", sa.Integer(), nullable=False,
server_default="0"),
@@ -83,9 +74,6 @@ def upgrade() -> None:
sa.Column("endpoint_id", UUID(as_uuid=True),
sa.ForeignKey("webhook_endpoints.id", ondelete="CASCADE"),
nullable=False),
# Stable across retries and covered by the signature, so a receiver can
# tell a redelivery from a genuine second event and make applying one
# twice a no-op. The same contract the module outbox already states.
sa.Column("event_id", UUID(as_uuid=True), nullable=False),
sa.Column("event_type", sa.String(120), nullable=False),
sa.Column("payload", JSONB(), nullable=False),
@@ -94,8 +82,6 @@ def upgrade() -> None:
sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
sa.Column("response_status", sa.Integer(), nullable=True),
# Truncated on write. A receiver returning a megabyte of HTML on error is
# common, and storing it turns a bad afternoon into a disk problem.
sa.Column("error", sa.String(1000), nullable=True),
sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
@@ -103,8 +89,6 @@ def upgrade() -> None:
)
op.create_index("ix_webhook_deliveries_endpoint", "webhook_deliveries",
["endpoint_id"])
# The worker's only query: what is due. Partial, because everything already
# delivered or given up on is dead weight in that index.
op.create_index(
"ix_webhook_deliveries_due", "webhook_deliveries",
["next_attempt_at"],
@@ -11,7 +11,6 @@ from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c37ba6143f83'
down_revision: Union[str, Sequence[str], None] = '91cc93992a91'
branch_labels: Union[str, Sequence[str], None] = None
@@ -20,13 +19,9 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('module_environments', sa.Column('provisioning_endpoint', sa.String(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('module_environments', 'provisioning_endpoint')
# ### end Alembic commands ###
@@ -36,14 +36,8 @@ depends_on: Union[str, Sequence[str], None] = None
BYPASS = "current_setting('app.bypass_rls', true) = 'on'"
CURRENT = "NULLIF(current_setting('app.tenant_id', true), '')::uuid"
# Tables whose rows belong strictly to one workspace. A NULL tenant_id here means
# a platform-level row — a superadmin account — which a workspace must not see.
STRICT = ("users", "tenant_modules")
# Tables where a NULL tenant_id means "shared across workspaces" and is meant to
# be readable. `roles` is the real case: `UserService.update_user` deliberately
# permits assigning a role with no tenant, so a workspace user can hold one, and
# loading `user.role` has to be able to see it.
SHARED_NULLS = ("roles", "sso_grants")
@@ -62,10 +56,6 @@ def upgrade() -> None:
for table in SHARED_NULLS:
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
# Asymmetric on purpose. Reading a shared row is fine; *creating* one is
# not — without that asymmetry a workspace could insert a role with no
# tenant and hand it to anybody, which is an escalation rather than a
# convenience.
op.execute(
f"""
CREATE POLICY tenant_isolation ON {table}
@@ -59,22 +59,14 @@ def upgrade() -> None:
server_default=sa.text("gen_random_uuid()")),
sa.Column("tenant_id", UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
# SET NULL rather than CASCADE: a document outliving the person who
# uploaded it is still the document, and removing it would take the
# attachment along with the leaver.
sa.Column("uploaded_by_id", UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
# What it is attached to. Nullable, because a document that belongs to
# the workspace rather than to any one record is an ordinary case.
sa.Column("entity_type", sa.String(60), nullable=True),
sa.Column("entity_id", sa.String(64), nullable=True),
# The name as uploaded, for display. Never used to build a path.
sa.Column("filename", sa.String(255), nullable=False),
# Determined by inspection, not taken from the client.
sa.Column("content_type", sa.String(120), nullable=False),
sa.Column("size_bytes", sa.BigInteger(), nullable=False),
sa.Column("checksum", sa.String(64), nullable=False),
# Random, unrelated to the filename, and unique across the platform.
sa.Column("storage_key", sa.String(120), nullable=False),
sa.Column("description", sa.String(500), nullable=True),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
@@ -84,8 +76,6 @@ def upgrade() -> None:
sa.UniqueConstraint("storage_key", name="uq_documents_storage_key"),
)
op.create_index("ix_documents_tenant", "documents", ["tenant_id"])
# The only lookup a screen makes: everything attached to this record. Partial
# so deleted rows, which are never listed, stay out of it.
op.create_index(
"ix_documents_entity", "documents", ["tenant_id", "entity_type", "entity_id"],
postgresql_where=sa.text("deleted_at IS NULL"),
@@ -64,8 +64,6 @@ def upgrade() -> None:
"Nothing has been changed."
)
# Normalise what is already stored, so the column and the index agree and so
# a lookup by the stored value keeps working.
connection.execute(
sa.text(
"UPDATE users SET email = lower(btrim(email)) "
@@ -73,13 +71,8 @@ def upgrade() -> None:
)
)
# On the expression, not the column: the column is normalised on write, and
# this is the guarantee that stays true if a write path ever forgets.
op.create_index(INDEX_NAME, "users", [sa.text("lower(email)")], unique=True)
def downgrade() -> None:
# The lower-casing is not undone. There is no record of what the original
# capitalisation was, and inventing one would be worse than leaving the
# addresses in a form that works.
op.drop_index(INDEX_NAME, table_name="users")
@@ -45,15 +45,11 @@ def upgrade() -> None:
"idempotency_records",
sa.Column("id", UUID(as_uuid=True), primary_key=True,
server_default=sa.text("gen_random_uuid()")),
# Nullable: a superadmin belongs to no workspace and can still retry.
sa.Column("tenant_id", UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
sa.Column("user_id", UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=True),
sa.Column("idempotency_key", sa.String(255), nullable=False),
# Method and path. Part of the identity because the same key sent to two
# different endpoints is two different requests, and answering the
# second with the first's response would be nonsense.
sa.Column("endpoint", sa.String(255), nullable=False),
sa.Column("request_hash", sa.String(64), nullable=False),
sa.Column("state", sa.String(20), nullable=False, server_default="in_progress"),
@@ -63,9 +59,6 @@ def upgrade() -> None:
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
# Scoped to the caller, not global: two customers picking the same
# random string must not collide, and a key is not a secret — a global
# namespace would let one workspace read another's response by guessing.
sa.UniqueConstraint("tenant_id", "user_id", "idempotency_key", "endpoint",
name="uq_idempotency_scope"),
)
@@ -75,9 +68,6 @@ def upgrade() -> None:
op.execute("ALTER TABLE idempotency_records ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE idempotency_records FORCE ROW LEVEL SECURITY")
# A NULL tenant is readable, as with user_mfa: these rows can belong to a
# platform superadmin, who has no workspace and still deserves working
# retries.
op.execute(
"""
CREATE POLICY tenant_isolation ON idempotency_records
@@ -11,7 +11,6 @@ from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'cd8ba77ffd9e'
down_revision: Union[str, Sequence[str], None] = '74b6ccfaee8e'
branch_labels: Union[str, Sequence[str], None] = None
@@ -20,13 +19,9 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('sso_grants', 'redirect_url')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('sso_grants', sa.Column('redirect_url', sa.VARCHAR(), autoincrement=False, nullable=False))
# ### end Alembic commands ###
@@ -37,12 +37,9 @@ def upgrade() -> None:
sa.Column("kind", sa.String(10), nullable=False, server_default="OIDC"),
sa.Column("name", sa.String(150), nullable=False),
sa.Column("slug", sa.String(100), nullable=False),
# Off until somebody has tested it: a half-configured provider that is
# live is a workspace nobody can sign in to.
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("issuer", sa.String(500), nullable=True),
sa.Column("client_id", sa.String(255), nullable=True),
# Encrypted, like the module trust credentials. Never returned by the API.
sa.Column("client_secret_enc", sa.Text(), nullable=True),
sa.Column("scopes", sa.String(500), nullable=False,
server_default="openid email profile"),
@@ -55,8 +52,6 @@ def upgrade() -> None:
server_default=sa.true()),
sa.Column("default_role_id", UUID(as_uuid=True),
sa.ForeignKey("roles.id", ondelete="SET NULL"), nullable=True),
# Off by default: letting an unrecognised subject claim an existing
# account by address is a takeover if the provider does not verify them.
sa.Column("link_existing_by_email", sa.Boolean(), nullable=False,
server_default=sa.false()),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
@@ -82,8 +77,6 @@ def upgrade() -> None:
sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
# The provider's subject is the identity. Unique per provider so two
# local accounts cannot both claim to be the same external person.
sa.UniqueConstraint("provider_id", "subject", name="uq_user_identity_subject"),
)
op.create_index("ix_user_identities_tenant", "user_identities", ["tenant_id"])
@@ -107,7 +100,6 @@ def upgrade() -> None:
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_sso_login_states_state", "sso_login_states", ["state"])
# The sweep that clears abandoned logins.
op.create_index("ix_sso_login_states_expiry", "sso_login_states", ["expires_at"])
for table in SCOPED:
@@ -42,28 +42,20 @@ def upgrade() -> None:
"notifications",
sa.Column("id", UUID(as_uuid=True), primary_key=True,
server_default=sa.text("gen_random_uuid()")),
# Nullable, so a platform superadmin can be told things too.
sa.Column("tenant_id", UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
sa.Column("user_id", UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
# A short machine-readable kind alongside the human text, so a client can
# choose an icon or route a click without parsing the title.
sa.Column("kind", sa.String(60), nullable=False),
sa.Column("severity", sa.String(20), nullable=False, server_default="info"),
sa.Column("title", sa.String(200), nullable=False),
sa.Column("body", sa.String(1000), nullable=True),
# Where to go about it. A notice with nothing to do about it is a notice
# people learn to ignore.
sa.Column("link", sa.String(500), nullable=True),
sa.Column("data", JSONB(), nullable=True),
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
)
# The only query the bell icon makes: this person's unread, newest first.
# Partial, because read rows are the overwhelming majority and are never
# counted.
op.create_index(
"ix_notifications_unread", "notifications", ["user_id", "created_at"],
postgresql_where=sa.text("read_at IS NULL"),
@@ -50,28 +50,17 @@ def upgrade() -> None:
"lookup_lists",
sa.Column("id", UUID(as_uuid=True), primary_key=True,
server_default=sa.text("gen_random_uuid()")),
# NULL means the platform owns it: visible everywhere, editable nowhere
# but here.
sa.Column("tenant_id", UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
# How code refers to it. Stable, lowercase, and the thing an integration
# names — so it cannot be renamed once anything depends on it.
sa.Column("code", sa.String(60), nullable=False),
sa.Column("name", sa.String(150), nullable=False),
sa.Column("description", sa.String(500), nullable=True),
# A platform list a workspace may add its own items to. False for lists
# where a customer's addition would be meaningless — ISO country codes,
# for instance.
sa.Column("allows_custom_items", sa.Boolean(), nullable=False,
server_default=sa.true()),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
)
# Unique per owner, so a workspace may have a `cost-centre` list of its own
# even though the platform also publishes one. NULLS NOT DISTINCT so two
# platform lists cannot share a code — without it, NULL != NULL and the
# constraint would not apply to the platform's own.
op.execute(
"CREATE UNIQUE INDEX uq_lookup_lists_code "
"ON lookup_lists (tenant_id, code) NULLS NOT DISTINCT"
@@ -81,9 +70,6 @@ def upgrade() -> None:
"lookup_items",
sa.Column("id", UUID(as_uuid=True), primary_key=True,
server_default=sa.text("gen_random_uuid()")),
# The item's own owner, which is not necessarily the list's: a workspace
# adding "Lahore office" to the platform's `location` list writes a row
# with its own tenant_id against a list with none.
sa.Column("tenant_id", UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
sa.Column("list_id", UUID(as_uuid=True),
@@ -91,13 +77,8 @@ def upgrade() -> None:
nullable=False),
sa.Column("code", sa.String(60), nullable=False),
sa.Column("label", sa.String(200), nullable=False),
# Where the common cases live, so a list needing one extra field does not
# need its own table. A list needing five should graduate to one.
sa.Column("metadata_json", JSONB(), nullable=True),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
# Retired rather than deleted: an item still referenced by historical
# records has to keep resolving, or a report from last year stops
# rendering.
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
@@ -111,10 +92,6 @@ def upgrade() -> None:
for table in ("lookup_lists", "lookup_items"):
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
# A NULL tenant is readable by everyone — that is what makes a platform
# list shared. Writing one is refused in the service rather than here,
# because the policy cannot tell "the platform seeding a list" from "a
# workspace writing a NULL", and both arrive on the same connection.
op.execute(
f"""
CREATE POLICY tenant_isolation ON {table}
@@ -50,9 +50,6 @@ def upgrade() -> None:
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
),
# SET NULL rather than CASCADE: deleting a plan must not erase the record
# that a workspace was once on it. History that disappears when the thing
# it describes is removed is not history.
sa.Column(
"from_plan_id",
UUID(as_uuid=True),
@@ -88,8 +85,6 @@ def upgrade() -> None:
["tenant_id", sa.text("created_at DESC")],
)
# Tenant-scoped, so it gets the same treatment as every other tenant table —
# a workspace's commercial history is its own business.
op.execute("ALTER TABLE tenant_subscription_history ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE tenant_subscription_history FORCE ROW LEVEL SECURITY")
op.execute(
@@ -41,12 +41,8 @@ def upgrade() -> None:
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("users",
sa.Column("deleted_by_id", UUID(as_uuid=True), nullable=True))
# The address they had. Kept so "who was that" stays answerable after the
# live `email` column has been tombstoned to release the address.
op.add_column("users", sa.Column("deleted_email", sa.String(255), nullable=True))
# Partial: almost every row is NULL, and the question asked is only ever
# "who has been deleted".
op.create_index(
"ix_users_deleted_at", "users", ["deleted_at"],
postgresql_where=sa.text("deleted_at IS NOT NULL"),
@@ -30,7 +30,6 @@ SCOPED = ("user_mfa", "mfa_recovery_codes")
def upgrade() -> None:
# --- lockout, on the account itself -------------------------------------
op.add_column(
"users",
sa.Column("failed_login_attempts", sa.Integer(), nullable=False,
@@ -44,39 +43,25 @@ def upgrade() -> None:
"users",
sa.Column("last_failed_login_at", sa.DateTime(timezone=True), nullable=True),
)
# Partial: almost every row is NULL, and the question asked is only ever
# "who is locked".
op.create_index(
"ix_users_locked_until", "users", ["locked_until"],
postgresql_where=sa.text("locked_until IS NOT NULL"),
)
# --- the second factor --------------------------------------------------
op.create_table(
"user_mfa",
sa.Column("id", UUID(as_uuid=True), primary_key=True,
server_default=sa.text("gen_random_uuid()")),
# Nullable, because a platform superadmin belongs to no workspace and
# needs a second factor more than anyone.
sa.Column("tenant_id", UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
sa.Column("user_id", UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
# Fernet-encrypted, like the module trust credentials. A TOTP secret in
# the clear is a second factor anybody with a database dump also has.
sa.Column("secret_enc", sa.Text(), nullable=False),
# NULL means enrolment started and was never proved. Such a factor is
# inactive — otherwise scanning the QR code and walking away would lock
# somebody out of their own account.
sa.Column("confirmed_at", sa.DateTime(timezone=True), nullable=True),
# The highest counter accepted. A TOTP code is valid for a whole step,
# so without this the same code works twice — and a code read over a
# shoulder is worth the rest of its window.
sa.Column("last_counter", sa.BigInteger(), nullable=True),
sa.Column("disabled_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
# One factor per person. Two would make "is MFA on" ambiguous.
sa.UniqueConstraint("user_id", name="uq_user_mfa_user"),
)
op.create_index("ix_user_mfa_user", "user_mfa", ["user_id"])
@@ -89,8 +74,6 @@ def upgrade() -> None:
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
sa.Column("user_id", UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
# Hashed, not encrypted. A recovery code is a credential the user holds;
# the platform only ever needs to check one, never to read it back.
sa.Column("code_hash", sa.String(255), nullable=False),
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
@@ -101,9 +84,6 @@ def upgrade() -> None:
for table in SCOPED:
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
# A NULL tenant is readable here, unlike `users`: these rows can belong
# to a platform superadmin, and hiding a superadmin's own second factor
# from them would make it impossible to manage.
op.execute(
f"""
CREATE POLICY tenant_isolation ON {table}
@@ -51,25 +51,10 @@ def upgrade() -> None:
)
op.create_index("ix_user_sessions_user", "user_sessions", ["user_id"])
op.create_index("ix_user_sessions_previous_jti", "user_sessions", ["previous_jti"])
# The sweep that clears expired rows, and the "is anything still live"
# question, both filter on these two together.
op.create_index(
"ix_user_sessions_live", "user_sessions", ["revoked_at", "expires_at"]
)
# Deliberately NOT row-level secured, unlike every other table carrying a
# tenant_id. Sessions belong to the authentication layer, which runs before
# workspace context exists: a session row is written during sign-in, when the
# platform does not yet know which workspace the account belongs to, and read
# during refresh, when the access token that would carry the context has
# already expired. A policy here does not isolate anything — it makes signing
# in fail, and the only way to keep it working is to bypass the policy on
# every path that touches the table, which is protection in name only.
#
# What does the isolating: every query is keyed on user_id, or on a jti that
# is a random UUID nobody can guess. `tenant_id` is carried for reporting —
# "how many workspaces signed in this week" — not for access control.
def downgrade() -> None:
op.drop_index("ix_user_sessions_live", table_name="user_sessions")
@@ -24,8 +24,6 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Module first, then date: the predicate filters on module and orders by
# date, so this is the order that lets the planner do both from the index.
op.create_index(
"ix_audit_logs_module_created", "audit_logs", ["module_name", "created_at"]
)
@@ -37,14 +37,6 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
# One index per column the search touches. A single index over a concatenated
# expression would be smaller, but it can only serve a query written the same
# way — and the query is four separate ILIKEs joined by OR, which is what the
# planner needs to match.
#
# On the **column**, not on `lower(column)`. An expression index only matches
# a query written as that same expression; `gin_trgm_ops` on the plain column
# is what `ILIKE` uses, and pg_trgm is already case-insensitive.
op.execute(
"CREATE INDEX IF NOT EXISTS ix_users_email_trgm "
"ON users USING gin (email gin_trgm_ops)"
@@ -58,8 +50,6 @@ def upgrade() -> None:
"ON users USING gin (last_name gin_trgm_ops)"
)
# The audit log's own search, for the same reason. It is the larger table and
# the one an investigation greps by entity name.
op.execute(
"CREATE INDEX IF NOT EXISTS ix_audit_entity_name_trgm "
"ON audit_logs USING gin (entity_name gin_trgm_ops)"
@@ -71,5 +61,3 @@ def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_users_last_name_trgm")
op.execute("DROP INDEX IF EXISTS ix_users_first_name_trgm")
op.execute("DROP INDEX IF EXISTS ix_users_email_trgm")
# The extension is deliberately left in place. Dropping it would break any
# other index built on it, and it costs nothing to keep.
@@ -41,23 +41,13 @@ def upgrade() -> None:
server_default=sa.text("gen_random_uuid()")),
sa.Column("tenant_id", UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
# Stored normalised, like users.email, and matched the same way. An
# invitation to Alice@ that creates a second account beside alice@ is
# precisely the duplicate the case-insensitivity work went in to prevent.
sa.Column("email", sa.String(255), nullable=False),
sa.Column("first_name", sa.String(100), nullable=True),
sa.Column("last_name", sa.String(100), nullable=True),
sa.Column("role_id", UUID(as_uuid=True),
sa.ForeignKey("roles.id", ondelete="SET NULL"), nullable=True),
# SET NULL rather than CASCADE: an invitation outliving the
# administrator who sent it is still a valid invitation, and deleting
# the record would erase who did it from the trail.
sa.Column("invited_by_id", UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
# SHA-256 of the token. Not bcrypt: this is looked *up* by value on a
# public route, so it has to be indexable, and a 32-byte random token
# has nothing to brute-force — the reason bcrypt exists is that human
# passwords do.
sa.Column("token_hash", sa.String(64), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("accepted_at", sa.DateTime(timezone=True), nullable=True),
@@ -66,15 +56,10 @@ def upgrade() -> None:
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
# Global, because the lookup on the public accept route has no workspace
# context — the token is the only thing the caller supplies.
sa.UniqueConstraint("token_hash", name="uq_user_invitations_token"),
)
op.create_index("ix_user_invitations_tenant", "user_invitations", ["tenant_id"])
# One live invitation per address per workspace. Sending a second supersedes
# the first rather than leaving two working tokens in two mailboxes — and
# partial, so the history of accepted and revoked ones is kept.
op.create_index(
"uq_user_invitations_pending",
"user_invitations",
@@ -85,9 +70,6 @@ def upgrade() -> None:
op.execute("ALTER TABLE user_invitations ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE user_invitations FORCE ROW LEVEL SECURITY")
# No `tenant_id IS NULL` arm here, unlike user_mfa: an invitation always
# belongs to a workspace, so a NULL would be a bug, and a policy that
# tolerated it would make that bug invisible.
op.execute(
"""
CREATE POLICY tenant_isolation ON user_invitations
@@ -37,16 +37,12 @@ def upgrade() -> None:
nullable=True,
),
)
# The listing is "this workspace, newest first", every time.
op.create_index(
"ix_audit_logs_tenant_created",
"audit_logs",
["tenant_id", sa.text("created_at DESC")],
)
# Same policy shape as `users` and `tenant_modules`: a NULL tenant is a
# platform row, hidden rather than shared. An unattributable entry from
# before this migration must not become readable by everyone.
op.execute("ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE audit_logs FORCE ROW LEVEL SECURITY")
op.execute(
@@ -43,16 +43,10 @@ def upgrade() -> None:
"notification_preferences",
sa.Column("id", UUID(as_uuid=True), primary_key=True,
server_default=sa.text("gen_random_uuid()")),
# Nullable, like `notifications` itself: a platform superadmin belongs to
# no workspace and still has preferences.
sa.Column("tenant_id", UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True),
sa.Column("user_id", UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
# The `kind` from `notification_service` — `webhook.disabled` and so on.
# Not constrained to a list in the database: a kind that stops existing
# would otherwise make an old preference row unwritable, and a stale
# preference for a kind nobody raises is harmless.
sa.Column("notification_type", sa.String(100), nullable=False),
sa.Column("channel", sa.String(50), nullable=False, server_default="in_app"),
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
@@ -63,7 +57,6 @@ def upgrade() -> None:
sa.UniqueConstraint("user_id", "notification_type", "channel",
name="uq_notification_preference"),
)
# The lookup on every notification raised: one person, one kind, one channel.
op.create_index(
"ix_notification_preferences_user", "notification_preferences",
["user_id", "notification_type"],
@@ -11,7 +11,6 @@ from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'f9cf173f48f9'
down_revision: Union[str, Sequence[str], None] = '73b754d5b2c5'
branch_labels: Union[str, Sequence[str], None] = None
@@ -20,7 +19,6 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('audit_logs', sa.Column('entity_id', sa.String(length=255), nullable=True))
op.add_column('audit_logs', sa.Column('entity_name', sa.String(length=255), nullable=True))
op.add_column('audit_logs', sa.Column('performed_by_id', sa.UUID(), nullable=True))
@@ -31,12 +29,10 @@ def upgrade() -> None:
op.create_index(op.f('ix_audit_logs_action_type'), 'audit_logs', ['action_type'], unique=False)
op.create_index(op.f('ix_audit_logs_created_at'), 'audit_logs', ['created_at'], unique=False)
op.create_index(op.f('ix_audit_logs_performed_by_email'), 'audit_logs', ['performed_by_email'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_audit_logs_performed_by_email'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_created_at'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_action_type'), table_name='audit_logs')
@@ -47,4 +43,3 @@ def downgrade() -> None:
op.drop_column('audit_logs', 'performed_by_id')
op.drop_column('audit_logs', 'entity_name')
op.drop_column('audit_logs', 'entity_id')
# ### end Alembic commands ###
+1 -55
View File
@@ -25,7 +25,6 @@ from app.config.database import SessionLocal
from app.core.redis import redis_client, sync_redis_client
from fastapi.concurrency import run_in_threadpool
# Configure logging
logging.basicConfig(
level=settings.LOG_LEVEL.upper(),
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
@@ -125,8 +124,6 @@ async def lifespan(app: FastAPI):
pass
def create_app() -> FastAPI:
# Registered before any session is created, so every query carries the
# workspace context the row-level security policies read.
from app.core.rls import register_rls_listener
register_rls_listener()
@@ -141,7 +138,6 @@ def create_app() -> FastAPI:
lifespan=lifespan,
)
# === OpenAPI Security Scheme ===
from fastapi.openapi.utils import get_openapi
def custom_openapi():
@@ -165,7 +161,6 @@ def create_app() -> FastAPI:
app.openapi = custom_openapi
# === CORS ===
origins = []
if settings.CORS_ALLOWED_ORIGINS:
origins = [
@@ -179,16 +174,10 @@ def create_app() -> FastAPI:
"CORS_ALLOWED_ORIGINS must be set when allow_credentials=True"
)
# Before CORS in source order means *outside* it at runtime, so the scope
# is established for every request that reaches a route.
from app.middleware.tenant_scope_middleware import TenantScopeMiddleware
app.add_middleware(TenantScopeMiddleware)
# Added after the scope middleware, so at runtime it sits *inside* it — the
# claim and the stored response both run with a workspace established. The
# service scopes its own queries explicitly regardless, because a middleware
# ordering is not a thing to depend on for isolation.
from app.middleware.idempotency_middleware import IdempotencyMiddleware
app.add_middleware(IdempotencyMiddleware)
@@ -199,14 +188,9 @@ def create_app() -> FastAPI:
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
# A browser hides every response header a server does not name here,
# whatever `allow_headers` says — that one is about the *request*. Without
# this the sign-in page cannot see that a second factor is required and
# would have to match on the English in the error message instead.
expose_headers=["X-MFA-Required", "Idempotent-Replay"],
)
# === Include Routers ===
from app.routes.auth.auth import router as auth_router
from app.routes.auth.tenant import router as tenant_router
from app.routes.auth.role import router as role_router
@@ -232,21 +216,12 @@ def create_app() -> FastAPI:
app.include_router(sso_internal_router, prefix="/internal/sso", tags=["Internal SSO"])
app.include_router(module_router, prefix="/api/modules", tags=["Modules"])
app.include_router(internal_module_router, prefix="/internal/modules", tags=["Internal Modules"])
# Public and unauthenticated: a JWKS document is public keys only, and a
# module needs it before it can trust anything else the platform says.
app.include_router(identity_router, prefix="/.well-known", tags=["Module Identity"])
# The module handoff: this platform signing a user INTO a module.
app.include_router(sso_public_router, prefix="/api/sso", tags=["SSO"])
# Identity providers: a workspace signing its people IN with their own
# directory. A different direction entirely, and mounted separately so the
# two are not confused — they already were once, by sharing a variable name.
app.include_router(idp_admin_router, prefix="/api/admin/sso", tags=["Admin - SSO"])
app.include_router(idp_public_router, prefix="/api/sso/idp", tags=["SSO - Identity Providers"])
# Invitations. Admin under /api/user because it is user management; the
# public pair separately, because a signed-out visitor holding a link is a
# different audience with a different threat model.
from app.routes.auth.invitation import (
public_router as invitation_public_router,
router as invitation_router,
@@ -256,27 +231,15 @@ def create_app() -> FastAPI:
app.include_router(invitation_public_router, prefix="/api/invitations",
tags=["Invitations"])
# Keys a customer automates with. Session-only to manage, so a leaked key
# cannot mint its replacement.
from app.routes.auth.api_key import router as api_key_router
app.include_router(api_key_router, prefix="/api/api-keys", tags=["API Keys"])
# A workspace's own webhook endpoints. Session-only to manage, for the
# same reason as API keys: a credential that can register a destination for
# your events is a credential that can quietly forward them.
from app.routes.system.webhook import router as webhook_router
app.include_router(webhook_router, prefix="/api/webhooks", tags=["Webhooks"])
# SCIM. Mounted at /scim/v2 rather than under /api, because every directory
# appends /Users and /Groups to whatever base URL the customer pastes in.
from app.routes.auth.scim import router as scim_router
app.include_router(scim_router, prefix="/scim/v2", tags=["SCIM"])
# A SCIM error body has a defined shape, and FastAPI's default handler would
# wrap it in {"detail": ...} — which no directory parses. Azure AD in
# particular reads an unparseable error as a transport failure and retries
# for ever, so the customer sees a sync that never finishes instead of the
# conflict that is blocking it.
from fastapi.responses import JSONResponse as _JSONResponse
from app.services.auth.scim_service import ScimError
@@ -288,43 +251,30 @@ def create_app() -> FastAPI:
media_type="application/scim+json",
)
# Your own notifications.
from app.routes.system.notification import router as notification_router
app.include_router(notification_router, prefix="/api/notifications",
tags=["Notifications"])
# Departments, branches, teams — and user administration scoped to one.
from app.routes.auth.org_unit import router as org_unit_router
app.include_router(org_unit_router, prefix="/api/org-units",
tags=["Organisation"])
# A workspace's own outgoing mail. Under /api/settings because that is
# where it is managed from, not under /api/admin — it is the customer's own
# configuration rather than something the platform does to them.
from app.routes.system.tenant_email import router as tenant_email_router
app.include_router(tenant_email_router, prefix="/api/settings/email",
tags=["Settings - Email"])
# Attachments. Downloads go through the API rather than a static path, so
# the session, the workspace and the permission are checked on every read.
from app.routes.system.document import router as document_router
app.include_router(document_router, prefix="/api/documents", tags=["Documents"])
# Reference lists. Reading needs only a session — a picker is needed by
# every screen, and a permission on it means a form that renders empty
# rather than one that refuses.
from app.routes.system.lookup import router as lookup_router
app.include_router(lookup_router, prefix="/api/reference", tags=["Reference data"])
# A second factor on your own account. Under /api/auth because that is what
# it is part of, and every route in it is scoped to the caller.
from app.routes.auth.mfa import router as mfa_router
app.include_router(mfa_router, prefix="/api/auth/mfa", tags=["Authentication - MFA"])
from app.routes.theme.color_palette import router as palette_router
app.include_router(palette_router, prefix="/api/theme", tags=["Theme Management"])
# === Admin Routes ===
from app.routes.admin.modules import router as admin_modules_router
from app.routes.admin.module_environments import router as admin_module_env_router
from app.routes.admin.tenant_modules import router as admin_tenant_modules_router
@@ -335,7 +285,6 @@ def create_app() -> FastAPI:
app.include_router(admin_module_env_router, prefix="/api/admin/modules", tags=["Admin - Module Environments"])
app.include_router(admin_tenant_modules_router, prefix="/api/admin/tenants", tags=["Admin - Tenant Modules"])
# === Basic Routes ===
@app.get("/", tags=["Root"])
def root():
"""
@@ -367,9 +316,6 @@ def create_app() -> FastAPI:
logger.error(f"Health check DB error: {e}")
db_status = "unhealthy"
# Reported rather than left to be discovered by whichever module calls
# the grant exchange first. Not fatal: the live sign-on flow uses
# per-environment HMAC and does not need this key.
from app.services.auth import module_identity
module_identity_status = (
@@ -384,4 +330,4 @@ def create_app() -> FastAPI:
"version": settings.VERSION,
}
return app
return app
+8 -14
View File
@@ -9,23 +9,21 @@ logger = logging.getLogger(__name__)
DATABASE_URL = settings.DATABASE_URL
# Build connection arguments based on SSL setting
connect_args = {"connect_timeout": 10}
if settings.DB_SSL:
connect_args["sslmode"] = "require"
# Create the SQLAlchemy engine with optimized connection pool
engine = create_engine(
DATABASE_URL,
pool_pre_ping=True,
pool_recycle=300, # Recycle connections every 5 minutes
pool_size=20, # Increased from 5 to handle higher concurrency
max_overflow=30, # Increased from 10 for peak load handling
pool_timeout=30, # Connection acquisition timeout
pool_reset_on_return='commit', # Reset connections on return
pool_recycle=300,
pool_size=20,
max_overflow=30,
pool_timeout=30,
pool_reset_on_return='commit',
connect_args=connect_args,
echo=False, # Disable SQL logging in production
future=True # Use SQLAlchemy 2.0 style
echo=False,
future=True
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@@ -36,18 +34,14 @@ def get_db():
try:
yield db
except HTTPException:
# Re-raise HTTPExceptions without logging as database errors
# These are application-level errors, not database errors
raise
except SQLAlchemyError as e:
# Log actual database errors
logger.error(f"Database error: {e}")
db.rollback()
raise
except Exception as e:
# Log other unexpected errors
logger.error(f"Unexpected database session error: {e}")
db.rollback()
raise
finally:
db.close()
db.close()
+1 -5
View File
@@ -64,7 +64,6 @@ class SecurityUtils:
expire = datetime.now(timezone.utc) + timedelta(seconds=settings.ACCESS_TOKEN_EXPIRES)
to_encode.update({"exp": expire, "type": "access", "jti": str(uuid.uuid4())})
# Include tenant_id if provided
if tenant_id:
to_encode["tenant_id"] = str(tenant_id)
@@ -94,7 +93,6 @@ class SecurityUtils:
"jti": str(jti) if jti else str(uuid.uuid4()),
})
# Include tenant_id if provided
if tenant_id:
to_encode["tenant_id"] = str(tenant_id)
@@ -161,8 +159,6 @@ class SecurityUtils:
detail="Invalid token type"
)
# Refresh tokens were never checked against the blacklist, so a
# rotated or logged-out token stayed valid for its full lifetime.
jti = payload.get("jti")
if jti and sync_redis_client.client:
try:
@@ -300,4 +296,4 @@ class SecurityUtils:
return re.match(ipv4_pattern, ip) is not None or re.match(ipv6_pattern, ip) is not None
security = SecurityUtils()
security = SecurityUtils()
+1 -66
View File
@@ -10,23 +10,6 @@ env_filename = f".env.{app_env}"
base_path = Path(__file__).resolve().parent.parent.parent
backend_path = Path(__file__).resolve().parent.parent
# What the process was actually started with.
#
# The `.env.*` files below load with `override=True`, which is right for their
# purpose — a per-environment file should beat the shared `.env` — and was also
# beating the real environment. That made an exported variable unable to redirect
# anything, which is how every container, every CI runner and every `VAR=x cmd`
# expects to configure a program.
#
# It was not a theoretical problem. With `APP_ENV` unset the default is
# `.env.local`, which points at a shared remote server; a CI job that set
# `DATABASE_URL` and forgot `APP_ENV` would have run its migrations against
# production, and `DATABASE_URL=... alembic upgrade head` silently went somewhere
# other than where it said.
#
# So: files fill in what the environment has not already said, and an explicit
# variable wins. The precedence is now the ordinary one — environment, then
# `.env.<APP_ENV>`, then `.env`.
_explicit = dict(os.environ)
load_dotenv(dotenv_path=base_path / '.env')
@@ -43,64 +26,34 @@ class Settings(BaseSettings):
PROJECT_NAME: str
VERSION: str
# FastAPI
PORT: int
HOST: str
APP_ENV: str
SECRET_KEY: str
ALLOWED_HOSTS: str = "*"
# Frontend
FRONTEND_URL: str
CORS_ALLOWED_ORIGINS: Optional[str] = None
CORS_ALLOW_ORIGIN_REGEX: Optional[str] = None
# Security
ENCRYPTION_KEY: Optional[str] = None
BCRYPT_ROUNDS: int = 12
# Public self-service signup. Off by default.
#
# The endpoint took the workspace to join from the X-Tenant-ID *request
# header*, so anyone knowing a workspace id could join it; and the frontend
# never sent that header at all, so every real signup created a tenant-less
# account — which the codebase used to treat as a platform superadmin.
# Accounts are created by workspace admins through the admin user routes,
# which are access-gated and workspace-scoped.
ALLOW_PUBLIC_SIGNUP: bool = False
# Inbound module requests may carry a timestamp and a nonce, signed
# alongside the body, so a captured request cannot be replayed. Off by
# default because deployed modules do not send them yet and turning it on
# without them would refuse every legitimate call. Turn it on once the
# modules are updated; until then a request without them is accepted and
# logged, and one *with* them is fully checked.
MODULE_TRUST_REQUIRE_REPLAY_CONTROLS: bool = False
# --- Alerting -----------------------------------------------------------
#
# Both empty means alerting is off, and the loop does nothing rather than
# computing counts nobody will see. Either one being set turns it on.
ALERT_WEBHOOK_URL: Optional[str] = None
ALERT_EMAIL: Optional[str] = None
# How long before the same open condition is mentioned again. Long enough
# not to become noise, short enough that an unattended problem resurfaces.
ALERT_RENOTIFY_MINUTES: int = 60
# A single overdue retry is a blip. This is where it becomes a situation.
ALERT_STUCK_EVENTS_THRESHOLD: int = 5
ALERT_STUCK_EVENTS_CRITICAL: int = 50
# Token reuse is scoped to a window, or one incident keeps the alert open
# for ever and it stops meaning "now".
ALERT_TOKEN_REUSE_WINDOW_HOURS: int = 24
# How far out of step a module's clock may be. Two minutes matches the
# outbound handoff's TTL.
MODULE_TRUST_MAX_SKEW_SECONDS: int = 120
# Database settings
DATABASE_URL: str
DB_SSL: bool = False
# Redis Configuration
REDIS_HOST: str
REDIS_PORT: int
REDIS_PASSWORD: Optional[str]
@@ -116,7 +69,6 @@ class Settings(BaseSettings):
return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
# Email
SMTP_HOST: str
SMTP_PORT: int
SMTP_SECURE: bool = True
@@ -124,46 +76,29 @@ class Settings(BaseSettings):
SMTP_PASSWORD: str
EMAIL_FROM: str
# JWT settings
ACCESS_TOKEN_SECRET: str
ACCESS_TOKEN_EXPIRES: int = 900
REFRESH_TOKEN_SECRET: str
REFRESH_TOKEN_EXPIRES: int = 864000
JWT_ALGORITHM: str = "HS256"
# Cookie settings
COOKIE_SECURE: bool = False
COOKIE_DOMAIN: Optional[str] = None
# Audit retention connects as its own role — SELECT and DELETE on
# audit_logs and nothing else — because the application role has UPDATE and
# DELETE revoked on that table. Unset, the retention job refuses to run
# rather than reporting success while deleting nothing.
AUDIT_RETENTION_DATABASE_URL: Optional[str] = None
# Documents
# A local directory, because there is no object-store credential in this
# deployment and inventing one would be inventing an infrastructure
# decision. `app.core.document_storage` is the seam if that changes.
DOCUMENT_STORAGE_PATH: str = "./storage/documents"
# Per file. Large enough for a scanned contract, small enough that one
# upload cannot fill a disk.
DOCUMENT_MAX_BYTES: int = 25 * 1024 * 1024
# Per workspace, across every live document. A cap rather than a plan
# entitlement for now: making it a plan field would be a pricing decision.
DOCUMENT_QUOTA_BYTES: int = 2 * 1024 * 1024 * 1024
# Super Admin Setup
SUPER_ADMIN_EMAIL: str
SUPER_ADMIN_PASSWORD: str
SUPER_ADMIN_FIRST_NAME: str = "Super"
SUPER_ADMIN_LAST_NAME: str = "Admin"
# Module Integration Security (RS256)
SAAS_PRIVATE_KEY: Optional[str] = None
SAAS_KEY_ID: str = "saas-key-v1"
# PayPal Integration
PAYPAL_CLIENT_ID: str
PAYPAL_CLIENT_SECRET: str
PAYPAL_MODE: str = "sandbox"
@@ -198,4 +133,4 @@ class Settings(BaseSettings):
"extra": "ignore",
}
settings = Settings()
settings = Settings()
+1 -2
View File
@@ -95,7 +95,6 @@ class RoleController:
for ra in role.role_accesses
]
# Add Module Permissions
accesses.extend([
{
"id": str(rma.module_access.id),
@@ -139,4 +138,4 @@ class RoleController:
filter_tenant_ids=filter_tenant_ids,
sort_by=sort_by,
sort_order=sort_order,
)
)
+1 -12
View File
@@ -47,9 +47,6 @@ class SSOController:
headers["X-Module-Signature"] = x_module_signature
if x_module_key:
headers["X-Module-Key"] = x_module_key
# Optional today. A module that sends them gets replay protection; one
# that does not is accepted and logged, until
# MODULE_TRUST_REQUIRE_REPLAY_CONTROLS is turned on.
for header in (
"X-Module-Signature-Version",
"X-Module-Timestamp",
@@ -61,14 +58,6 @@ class SSOController:
actual_body = payload.model_dump_json()
# There is deliberately no fallback here.
#
# This used to retry verification with an empty body when verification
# over the real body failed. The HMAC of the empty string is a constant
# per module environment, so anyone who ever observed that one signature
# could sign any grant-exchange body, forever — the check reported as
# protected while being permanently forgeable. A compatibility shim that
# disables the control it is shimming is worse than no control.
TrustService.validate_module_trust(
environment=env,
request_headers=headers,
@@ -80,4 +69,4 @@ class SSOController:
grant_code=payload.grant_code,
module_id=payload.module_id,
environment_slug=payload.environment_slug
)
)
+1 -9
View File
@@ -10,9 +10,6 @@ from app.middleware.tenant_middleware import is_superadmin
class UserController:
@staticmethod
def _resolve_tenant_id(current_user: User, requested_tenant_id: Optional[uuid.UUID]) -> Optional[uuid.UUID]:
# Only an explicit superadmin may act on a tenant other than their own.
# This used to be `current_user.tenant_id is None`, which handed the same
# power to any tenant-less account the signup endpoint happened to create.
if is_superadmin(current_user):
return requested_tenant_id
@@ -82,8 +79,6 @@ class UserController:
UserController._resolve_tenant_id(current_user, user_data.tenant_id)
tenant_id = UserController._scoped_tenant_id(current_user)
# Checked before the edit, not only before the read. A scoped
# administrator who guessed an id must not be able to write to it.
UserService.get_user_by_id(
db, user_id, tenant_id,
visible_ids=UserController._visible_ids(db, current_user),
@@ -93,9 +88,6 @@ class UserController:
@staticmethod
def delete_user(db: Session, user_id: uuid.UUID, current_user: User):
tenant_id = UserController._scoped_tenant_id(current_user)
# Who did it, on the row itself. The audit log says so too, but a
# restored-from-backup database and a truncated audit table are both
# ordinary events, and this is the copy that travels with the record.
UserService.get_user_by_id(
db, user_id, tenant_id,
visible_ids=UserController._visible_ids(db, current_user),
@@ -146,4 +138,4 @@ class UserController:
sort_by=sort_by,
sort_order=sort_order,
visible_ids=visible,
)
)
-2
View File
@@ -44,8 +44,6 @@ def _fernet() -> Fernet:
"ENCRYPTION_KEY is not set. Module trust credentials cannot be "
"encrypted or decrypted without it."
)
# SHA-256 of the configured secret, base64url-encoded — a valid 32-byte
# Fernet key derived deterministically from any input string.
digest = hashlib.sha256(key.encode("utf-8")).digest()
return Fernet(base64.urlsafe_b64encode(digest))
-2
View File
@@ -29,8 +29,6 @@ from typing import BinaryIO, Iterator
logger = logging.getLogger(__name__)
# Read in chunks so a large upload never sits in memory whole. 64 KiB is what
# most filesystems hand back per read anyway.
CHUNK_BYTES = 64 * 1024
+1 -25
View File
@@ -29,21 +29,16 @@ from __future__ import annotations
from typing import Optional
# (magic bytes, offset, content type, extension). Order matters only in that the
# first match wins, and no two prefixes here overlap.
_SIGNATURES: tuple[tuple[bytes, int, str, str], ...] = (
(b"%PDF-", 0, "application/pdf", "pdf"),
(b"\x89PNG\r\n\x1a\n", 0, "image/png", "png"),
(b"\xff\xd8\xff", 0, "image/jpeg", "jpg"),
(b"GIF87a", 0, "image/gif", "gif"),
(b"GIF89a", 0, "image/gif", "gif"),
(b"RIFF", 0, "image/webp", "webp"), # narrowed by the WEBP check below
(b"RIFF", 0, "image/webp", "webp"),
(b"%!PS", 0, "application/postscript", "ps"),
)
# Office formats and anything else zip-based share one signature, so they cannot
# be told apart by magic bytes alone. The extension decides *which* of them it
# is; the signature decides that it is one of them at all.
_ZIP_MAGIC = b"PK\x03\x04"
_ZIP_TYPES = {
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
@@ -52,12 +47,8 @@ _ZIP_TYPES = {
"zip": "application/zip",
}
# The only extensions that may be taken as text. Everything else has to prove
# itself with a signature.
TEXT_EXTENSIONS = {"txt", "csv", "log", "md"}
# Types a browser will not execute, served as themselves so a person can look at
# an invoice without downloading it. Everything else is forced to a download.
INLINE_SAFE = {"application/pdf", "image/png", "image/jpeg", "image/gif", "image/webp"}
ALLOWED = set(INLINE_SAFE) | set(_ZIP_TYPES.values()) | {"text/plain", "text/csv"}
@@ -93,30 +84,15 @@ def sniff(head: bytes, filename: str) -> Optional[str]:
for magic, offset, content_type, _ in _SIGNATURES:
if head[offset:offset + len(magic)] == magic:
if content_type == "image/webp":
# RIFF is a container for several formats; only one of them is
# an image we want to serve.
if head[8:12] != b"WEBP":
continue
if content_type == "application/postscript":
# Recognised so it can be refused explicitly rather than falling
# through to "not a file type we accept", which reads as a bug.
return None
return content_type
if head[:len(_ZIP_MAGIC)] == _ZIP_MAGIC:
# Which zip-based format it is comes from the name; that it is one at all
# came from the bytes. A `.docx` that is really a `.zip` full of scripts
# is still a zip, and is still only ever handed back as a download.
return _ZIP_TYPES.get(_extension(filename), "application/zip")
# Text is the only type with no signature to check, so it is the only one
# where the *name* has to agree. Without that, anything printable is text:
# an HTML document named `innocent.png` sniffs as text/plain and is stored,
# and so does an SVG, which is a script host wearing an image's name.
#
# It is not exploitable on the way out — text is served `attachment` with
# `nosniff` — but "we accepted it and called it something else" is a worse
# answer than "that is not a file we take", and the second one is true.
extension = _extension(filename)
if extension in TEXT_EXTENSIONS and _looks_like_text(head):
return "text/csv" if extension == "csv" else "text/plain"
-1
View File
@@ -97,7 +97,6 @@ def register_rls_listener() -> None:
@event.listens_for(Session, "after_transaction_end")
def _forget(session: Session, transaction: Any) -> None:
# The transaction-local settings are gone, so the cached state is stale.
session.info.pop(_STATE_KEY, None)
logger.debug("row-level security context listener registered")
-2
View File
@@ -103,8 +103,6 @@ def _url_shape_error(parts: SplitResult, require_https: bool) -> Optional[str]:
if not parts.hostname:
return "URL has no host"
if parts.username or parts.password:
# `https://evil@internal/` — the part before the @ is not the host, and
# a reader skimming a configuration screen will believe it is.
return "URL must not contain credentials"
return None
-17
View File
@@ -53,9 +53,6 @@ def get_current_user(
detail="Not authenticated"
)
# An API key arrives in the same place a session token does, so that a
# client library has one thing to set. It resolves to the user who issued
# it, carrying that key's scopes — see `_user_for_api_key`.
if api_key_service.looks_like_a_key(token):
return _user_for_api_key(request, token, db)
@@ -75,9 +72,6 @@ def get_current_user(
detail="Could not validate credentials"
)
# Resolving the caller happens before their workspace is known, so it is
# necessarily unscoped. Everything after `set_request_tenant` below runs
# inside their workspace.
with unscoped():
user = db.query(User).filter(User.id == user_id).first()
if user is None:
@@ -87,10 +81,6 @@ def get_current_user(
)
setattr(user, "_saas_db_session", db)
# Cleared, not merely left unset. A `User` is an ORM instance, and the same
# instance can be handed back from the session's identity map to a later
# request — so a key's ceiling, set on a previous request, would otherwise
# still be attached to a caller who signed in with a password.
_forget_api_key(user)
if user.status != "active":
@@ -99,8 +89,6 @@ def get_current_user(
detail="User is inactive"
)
# Shared with the API-key path below. Two copies of "is this workspace
# allowed to do things" is how one of them ends up missing a state.
_assert_workspace_usable(request, user, db)
return user
@@ -126,7 +114,6 @@ def _user_for_api_key(request: Request, raw: str, db: Session) -> User:
"""
key = api_key_service.resolve(db, raw)
if key is None:
# One answer for unknown, revoked, expired and mistyped alike.
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
@@ -135,8 +122,6 @@ def _user_for_api_key(request: Request, raw: str, db: Session) -> User:
with unscoped():
owner = db.query(User).filter(User.id == key.user_id).first()
if owner is None or owner.status != "active":
# The owner left. The key dies with them, rather than becoming a
# credential nobody is accountable for.
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
@@ -206,8 +191,6 @@ def has_access(user: User, access_code: str) -> bool:
if access_code not in user_access_codes:
return False
# An API key is a ceiling on its owner, never an extension of them. Present
# only when the caller authenticated with one.
scopes = getattr(user, "_saas_api_key_scopes", None)
return access_code in scopes if scopes is not None else True
-15
View File
@@ -62,8 +62,6 @@ def _caller(request: Request) -> Optional[tuple[Optional[uuid.UUID], Optional[uu
db = SessionLocal()
try:
key = api_key_service.resolve(db, presented)
# The key's owner, not the key: a client that rotates its key
# mid-retry is still the same caller making the same request.
return (key.tenant_id, key.user_id) if key else None
except Exception:
logger.exception("could not resolve an API key for idempotency")
@@ -89,16 +87,11 @@ class IdempotencyMiddleware(BaseHTTPMiddleware):
caller = _caller(request)
if caller is None:
# Not identified. Passed through rather than refused: the request is
# about to be rejected on its own merits, and claiming a key for an
# unknown caller would let anybody occupy anybody else's.
return await call_next(request)
tenant_id, user_id = caller
endpoint = f"{request.method} {request.url.path}"
# Read once and put back, because a Starlette request body is a stream
# and the endpoint downstream still needs it.
body = await request.body()
async def _receive():
@@ -123,9 +116,6 @@ class IdempotencyMiddleware(BaseHTTPMiddleware):
return JSONResponse({"detail": conflict.detail},
status_code=conflict.status_code)
except Exception:
# A failure in the bookkeeping must not refuse a valid request. The
# caller loses the guarantee for this attempt, which is exactly
# where they were before the header existed.
logger.exception("idempotency claim failed; proceeding without it")
db.close()
return await call_next(request)
@@ -135,8 +125,6 @@ class IdempotencyMiddleware(BaseHTTPMiddleware):
return JSONResponse(
replay.response_body,
status_code=replay.response_status or 200,
# So a client can tell a replay from a fresh execution — the
# difference matters when they are reconciling their own logs.
headers={"Idempotent-Replay": "true"},
)
@@ -145,9 +133,6 @@ class IdempotencyMiddleware(BaseHTTPMiddleware):
try:
response = await call_next(request)
except Exception:
# The handler blew up. Release the claim, or a client retrying after
# a genuine server error is told to wait for a request that is never
# coming back.
idempotency_service.release(
db, key=key, endpoint=endpoint,
tenant_id=tenant_id, user_id=user_id,
-4
View File
@@ -58,7 +58,6 @@ def _consume(bucket: str, limit: int, window_seconds: int) -> Optional[int]:
pipe.ttl(key)
count, ttl = pipe.execute()
# A fresh counter, or one that somehow lost its expiry, gets the window.
if ttl is None or ttl < 0:
client.expire(key, window_seconds)
ttl = window_seconds
@@ -95,7 +94,6 @@ def rate_limit(
if value:
buckets.append(f"{name}:{by_body_field}:{str(value).lower()}")
except Exception:
# An unparseable body is the route's problem to report, not ours.
pass
for bucket in buckets:
@@ -111,8 +109,6 @@ def rate_limit(
return dependency
# Endpoint budgets. Login is the tightest because it is the one being guessed;
# forgot-password is tight because each call sends mail.
SIGNIN_LIMIT = rate_limit("signin", limit=10, window_seconds=900, by_body_field="email")
SIGNUP_LIMIT = rate_limit("signup", limit=5, window_seconds=3600)
FORGOT_PASSWORD_LIMIT = rate_limit(
-14
View File
@@ -45,7 +45,6 @@ def _claims(request: Request) -> dict | None:
try:
return jwt.decode(token, settings.ACCESS_TOKEN_SECRET, algorithms=["HS256"])
except jwt.InvalidTokenError:
# Not this middleware's job to complain — get_current_user will.
return None
@@ -89,25 +88,12 @@ class TenantScopeMiddleware(BaseHTTPMiddleware):
if resolved is not None:
tenant_id, key_name = resolved
request.state.tenant_id = str(tenant_id)
# The name travels with the request so the audit trail can
# say which integration acted, rather than only naming the
# person the key belongs to.
with scoped_to(tenant_id), api_key_service.acting_as(key_name):
return await call_next(request)
# An unresolvable key falls through unscoped and is refused a
# moment later, which is where refusals belong.
# No usable token: sign-in, password reset, health checks. These run
# with no workspace set, which means row-level security shows them
# nothing — and the handful of queries that legitimately need to look
# across workspaces before anyone is authenticated say so explicitly
# with `unscoped()`.
return await call_next(request)
if claims.get("is_superadmin"):
# A platform superadmin belongs to no workspace. Scoping them to
# "no workspace" would empty every administrative screen, so they
# bypass instead.
with unscoped():
return await call_next(request)
-18
View File
@@ -36,8 +36,6 @@ from app.config.database import Base
class IdentityProviderKind(str, enum.Enum):
OIDC = "OIDC"
# SAML is not implemented. The column exists so adding it later is a value
# rather than a migration, and so nothing reads a kind it does not handle.
SAML = "SAML"
@@ -54,41 +52,26 @@ class IdentityProvider(Base):
kind = Column(String(10), nullable=False, default=IdentityProviderKind.OIDC.value)
name = Column(String(150), nullable=False)
# What appears in the URL a user is sent to. Stable, so a bookmarked login
# link keeps working when the display name is edited.
slug = Column(String(100), nullable=False)
# Off until somebody has tested it. A provider that is half-configured and
# live is a workspace nobody can sign in to.
enabled = Column(Boolean, nullable=False, default=False)
issuer = Column(String(500), nullable=True)
client_id = Column(String(255), nullable=True)
# Encrypted at rest, like the module trust credentials. Never returned by
# the API — a secret that can be read back is a secret in every log of
# every response.
client_secret_enc = Column(Text, nullable=True)
scopes = Column(String(500), nullable=False, default="openid email profile")
# Filled in from the issuer's discovery document rather than typed, which is
# both less error-prone and how a provider signals a change of endpoint.
authorization_endpoint = Column(String(500), nullable=True)
token_endpoint = Column(String(500), nullable=True)
jwks_uri = Column(String(500), nullable=True)
discovered_at = Column(DateTime(timezone=True), nullable=True)
# Comma-separated. Empty means any domain the provider vouches for. Set, it
# is the difference between "our staff" and "anyone with a Google account".
allowed_domains = Column(Text, nullable=True)
# Whether somebody who authenticates but has no account here gets one.
jit_provisioning = Column(Boolean, nullable=False, default=True)
default_role_id = Column(
UUID(as_uuid=True), ForeignKey("roles.id", ondelete="SET NULL"), nullable=True
)
# Whether an unrecognised `sub` may claim an existing local account with the
# same address. Convenient, and a takeover if the provider does not verify
# addresses — so it is off unless deliberately turned on.
link_existing_by_email = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
@@ -185,7 +168,6 @@ class SsoLoginState(Base):
nonce = Column(String(128), nullable=False)
code_verifier = Column(String(256), nullable=False)
# Where the user was going before they were sent to sign in.
redirect_to = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+1 -5
View File
@@ -21,9 +21,6 @@ class ModuleEnvironment(Base):
trust_type = Column(String, nullable=False)
# Legacy plaintext column. Blanked by migration b2e1d4f5a602 and kept only so
# a downgrade has somewhere to put the secrets back. Read through
# `credentials` — never directly.
trust_credentials = Column(JSON, nullable=False)
trust_credentials_enc = Column(Text, nullable=True)
@@ -54,7 +51,6 @@ class ModuleEnvironment(Base):
from app.core.crypto import encrypt_json
self.trust_credentials_enc = encrypt_json(value or {})
# Never leave a plaintext copy behind.
self.trust_credentials = {}
__table_args__ = (
@@ -62,4 +58,4 @@ class ModuleEnvironment(Base):
)
def __repr__(self):
return f"<ModuleEnvironment {self.slug} for {self.module_id}>"
return f"<ModuleEnvironment {self.slug} for {self.module_id}>"
-5
View File
@@ -19,17 +19,12 @@ class OrgUnit(Base):
name = Column(String(160), nullable=False)
code = Column(String(60))
parent_id = Column(UUID(as_uuid=True), ForeignKey("org_units.id", ondelete="RESTRICT"))
# Materialised ancestry, `/id/id/`, ending in this unit's own id.
path = Column(Text, nullable=False, default="/")
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
# Soft delete: a removed branch is still named by audit entries, by seat
# allocations, and by whatever a report grouped by last quarter.
deleted_at = Column(DateTime(timezone=True))
deleted_by_id = Column(UUID(as_uuid=True))
# `lazy="raise"` throughout this codebase: a relationship that loads itself
# on attribute access is how an N+1 gets written without anybody deciding to.
parent = relationship("OrgUnit", remote_side=[id], lazy="raise")
@property
-1
View File
@@ -13,7 +13,6 @@ class RoleAccess(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
role = relationship("Role", back_populates="role_accesses")
access = relationship("Access", back_populates="role_accesses")
-2
View File
@@ -19,8 +19,6 @@ class OrgUnitSeatAllocation(Base):
org_unit_id = Column(UUID(as_uuid=True),
ForeignKey("org_units.id", ondelete="CASCADE"),
nullable=False)
# A cap, not a reservation: nine seats on a branch with two people does not
# withhold the other seven from anybody else.
seat_limit = Column(Integer, nullable=False)
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at = Column(DateTime(timezone=True), nullable=False,
+1 -2
View File
@@ -15,11 +15,10 @@ class SSOGrant(Base):
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True)
environment_slug = Column(String, nullable=False)
# redirect_url removed - stateless grants
is_used = Column(Boolean, default=False)
used_at = Column(DateTime(timezone=True), nullable=True)
expires_at = Column(DateTime(timezone=True), nullable=False) # 60 seconds
expires_at = Column(DateTime(timezone=True), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
@@ -13,8 +13,6 @@ class SubscriptionPlan(Base):
price = Column(Numeric(10, 2), nullable=True)
duration_days = Column(Integer, nullable=True)
max_users_allowed = Column(Integer, nullable=True)
# How long after expiry a workspace stays read-only rather than locked out.
# Per plan, because it is a commercial decision. 0 keeps the old behaviour.
grace_period_days = Column(Integer, nullable=False, server_default="0", default=0)
is_public = Column(Boolean, default=True)
status = Column(String, default="active")
@@ -24,7 +22,6 @@ class SubscriptionPlan(Base):
DateTime(timezone=True), onupdate=func.now(), server_default=func.now()
)
# Relationships
tenants = relationship("Tenant", back_populates="plan")
plan_accesses = relationship("PlanAccess", back_populates="plan", cascade="all, delete-orphan")
plan_module_accesses = relationship("PlanModuleAccess", back_populates="plan", cascade="all, delete-orphan")
+1 -9
View File
@@ -16,11 +16,7 @@ class Tenant(Base):
start_date = Column(Date, nullable=True)
end_date = Column(Date, nullable=True)
status = Column(String, nullable=False, default="ACTIVE", index=True)
# Cancellation is a decision with a date, not the absence of a flag.
cancelled_at = Column(DateTime(timezone=True), nullable=True)
# Who to tell when the subscription is about to lapse. A workspace has users,
# not an owner — without this there is nobody specific to write to, and the
# alternative is emailing everyone, which is how notices become noise.
billing_email = Column(String(255), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
@@ -28,13 +24,9 @@ class Tenant(Base):
DateTime(timezone=True), onupdate=func.now(), server_default=func.now()
)
# Soft delete. Every audit entry, subscription record and history row points
# at this row; removing it makes all of them unattributable at once, and
# there is no way back.
deleted_at = Column(DateTime(timezone=True), nullable=True)
deleted_by_id = Column(UUID(as_uuid=True), nullable=True)
# Relationships
users = relationship("User", back_populates="tenant", cascade="all, delete-orphan")
roles = relationship("Role", back_populates="tenant", cascade="all, delete-orphan")
tenant_modules = relationship("TenantModule", back_populates="tenant", cascade="all, delete-orphan")
@@ -49,4 +41,4 @@ class Tenant(Base):
return self.deleted_at is not None
def __repr__(self):
return f"<Tenant {self.tenant_name}>"
return f"<Tenant {self.tenant_name}>"
-12
View File
@@ -16,16 +16,10 @@ class User(Base):
preferred_language = Column(String, default="en", nullable=True)
status = Column(String, default="active", nullable=False)
# Consecutive failures against *this account*. Rate limiting throttles a
# source; an attacker spreading attempts across addresses defeats that and
# leaves no record against the person being attacked.
failed_login_attempts = Column(Integer, default=0, nullable=False)
locked_until = Column(DateTime(timezone=True), nullable=True)
last_failed_login_at = Column(DateTime(timezone=True), nullable=True)
# Platform superadmin. Explicit, because this used to be inferred from a NULL
# tenant_id — which meant every tenant-less account, including any produced by
# the public signup endpoint, was treated as a superadmin.
is_superadmin = Column(Boolean, default=False, nullable=False)
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True)
@@ -36,13 +30,8 @@ class User(Base):
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now())
password_updated_at = Column(DateTime(timezone=True), server_default=func.now())
# Soft delete. The row survives so the record of who this account was
# survives with it; every path that matters — signing in, listings, seat
# counting, SCIM — treats a marked row as gone.
deleted_at = Column(DateTime(timezone=True), nullable=True)
deleted_by_id = Column(UUID(as_uuid=True), nullable=True)
# The address they had. `email` is tombstoned on deletion to release the
# globally unique address; this is where the real one is kept.
deleted_email = Column(String, nullable=True)
@property
@@ -54,4 +43,3 @@ class User(Base):
def __repr__(self):
return f"<User {self.email}>"
-2
View File
@@ -17,8 +17,6 @@ class AlertState(Base):
__tablename__ = "alert_state"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# One row per condition, not per occurrence. "The outbox is stuck" is a
# single situation however many events are caught in it.
alert_key = Column(String(60), nullable=False, unique=True, index=True)
severity = Column(String(20), nullable=False)
detail = Column(Text, nullable=True)
+1 -5
View File
@@ -9,10 +9,6 @@ class AuditLog(Base):
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
# Nullable because entries written before the table had a workspace column
# cannot be attributed. A NULL is a platform row: visible to superadmins,
# hidden from workspaces — the safe reading of "we do not know whose this
# was". See migration f6b8c2d4e104.
tenant_id = Column(
UUID(as_uuid=True),
ForeignKey("tenants.id", ondelete="SET NULL"),
@@ -46,4 +42,4 @@ class AuditLog(Base):
return (
f"<AuditLog {self.action_type} on {self.module_name}"
f" by {self.performed_by_email}"
)
)
-3
View File
@@ -19,10 +19,7 @@ class Document(Base):
ForeignKey("users.id", ondelete="SET NULL"))
entity_type = Column(String(60))
entity_id = Column(String(64))
# The name as uploaded, for display. Never used to build a path — see
# `document_storage.new_key` for why.
filename = Column(String(255), nullable=False)
# Determined from the bytes, not taken from the client.
content_type = Column(String(120), nullable=False)
size_bytes = Column(BigInteger, nullable=False)
checksum = Column(String(64), nullable=False)
+1 -1
View File
@@ -13,7 +13,7 @@ class EventLog(Base):
__tablename__ = "event_logs"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
event_id = Column(UUID(as_uuid=True), nullable=False, index=True) # Idempotency Key
event_id = Column(UUID(as_uuid=True), nullable=False, index=True)
event_type = Column(String, nullable=False)
payload = Column(JSONB, nullable=False)
-5
View File
@@ -22,8 +22,6 @@ class LookupList(Base):
id = Column(UUID(as_uuid=True), primary_key=True,
server_default=func.gen_random_uuid())
# NULL means the platform owns it: visible to every workspace, editable by
# none of them.
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"))
code = Column(String(60), nullable=False)
name = Column(String(150), nullable=False)
@@ -45,9 +43,6 @@ class LookupItem(Base):
id = Column(UUID(as_uuid=True), primary_key=True,
server_default=func.gen_random_uuid())
# Not necessarily the list's owner: a workspace adding its own office to the
# platform's `location` list writes its own tenant_id against a list with
# none.
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"))
list_id = Column(UUID(as_uuid=True),
ForeignKey("lookup_lists.id", ondelete="CASCADE"), nullable=False)
-8
View File
@@ -25,8 +25,6 @@ class UserMfa(Base):
__tablename__ = "user_mfa"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# Nullable: a platform superadmin belongs to no workspace, and needs a
# second factor more than anyone.
tenant_id = Column(
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=True, index=True,
@@ -38,14 +36,8 @@ class UserMfa(Base):
secret_enc = Column(Text, nullable=False)
# NULL means enrolment was started and never proved. Such a factor is
# inactive — otherwise scanning the QR code and walking away would lock
# somebody out of their own account.
confirmed_at = Column(DateTime(timezone=True), nullable=True)
# The highest counter accepted. A TOTP code is valid for a whole step, so
# without this the same code works twice, and one read over a shoulder is
# worth the rest of its window.
last_counter = Column(BigInteger, nullable=True)
disabled_at = Column(DateTime(timezone=True), nullable=True)
@@ -11,8 +11,6 @@ from sqlalchemy.dialects.postgresql import UUID
from app.config.database import Base
# The channels a notification can arrive by. Only the first exists today; the
# others are named so the column does not have to change when they do.
IN_APP = "in_app"
EMAIL = "email"
CHANNELS = (IN_APP, EMAIL)
@@ -28,8 +28,6 @@ class TenantSubscriptionHistory(Base):
UUID(as_uuid=True), ForeignKey("subscription_plans.id", ondelete="SET NULL"), nullable=True
)
# new | upgrade | downgrade | renewal | extension | cancellation | suspension
# | reactivation | expiry
change_type = Column(String(30), nullable=False)
from_end_date = Column(Date, nullable=True)
@@ -40,7 +38,6 @@ class TenantSubscriptionHistory(Base):
changed_by_id = Column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
# Kept alongside the id because a deleted account should not erase who acted.
changed_by_email = Column(String, nullable=True)
notes = Column(Text, nullable=True)
@@ -22,7 +22,6 @@ class SubscriptionNotice(Base):
nullable=False, index=True,
)
# expiring_soon | grace_started | expired
kind = Column(String(30), nullable=False)
for_end_date = Column(Date, nullable=True)
sent_to = Column(String(255), nullable=True)
-12
View File
@@ -27,22 +27,11 @@ class UserSession(Base):
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"),
nullable=False, index=True,
)
# Carried for reporting, not for access control. This table is deliberately
# outside row-level security: it is written during sign-in, before workspace
# context exists, and read during refresh, when the access token that would
# carry that context has expired. See migration e5a7b9c1d003.
tenant_id = Column(UUID(as_uuid=True), nullable=True, index=True)
# The jti of the refresh token that is currently valid for this session.
current_jti = Column(UUID(as_uuid=True), nullable=False, unique=True, index=True)
# The one it replaced. Presenting this is not a mistake — it is the
# signature of a stolen token being used after the real client rotated, and
# it ends the session rather than merely being refused.
previous_jti = Column(UUID(as_uuid=True), nullable=True, index=True)
# Recorded so a session list means something to the person reading it.
# "Chrome on Windows, Bengaluru, two minutes ago" is reviewable; a row of
# opaque identifiers is not.
user_agent = Column(String(512), nullable=True)
ip_address = Column(INET, nullable=True)
@@ -51,7 +40,6 @@ class UserSession(Base):
expires_at = Column(DateTime(timezone=True), nullable=False)
revoked_at = Column(DateTime(timezone=True), nullable=True)
# signed_out | rotated_elsewhere | reuse_detected | password_changed | admin
revoked_reason = Column(String(40), nullable=True)
@property
+1 -4
View File
@@ -43,9 +43,6 @@ def get_audit_logs(
"""
query = db.query(AuditLog)
# Belt and braces. The row-level policy already restricts this to the
# caller's workspace; the explicit filter means the endpoint is still correct
# if it is ever read through a connection that can bypass.
if not is_superadmin(current_user):
query = query.filter(AuditLog.tenant_id == current_user.tenant_id)
@@ -99,4 +96,4 @@ def get_audit_logs(
total=total,
limit=limit,
offset=offset,
)
)
-5
View File
@@ -33,7 +33,6 @@ def create_module(
):
result = ModuleController.create_module(db, module_data)
# Professional Audit Logging
AuditLogService.log(
db=db,
module_name="Modules",
@@ -67,7 +66,6 @@ def update_module(
_: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db)
):
# Snapshot before update
existing = ModuleController.get_module(db, module_id)
old_values = {"name": existing.module_name, "status": existing.status}
@@ -97,7 +95,6 @@ def delete_module(
_: bool = Depends(require_access("modules.manage")),
db: Session = Depends(get_db)
):
# Capture name for the log before it's deleted
existing = ModuleController.get_module(db, module_id)
module_name = existing.module_name
@@ -117,7 +114,6 @@ def delete_module(
)
return result
# ── Permission Sync Routes ──────────────────────────────────────────────────
@router.get("/{module_id}/permissions")
def get_module_permissions(
@@ -126,7 +122,6 @@ def get_module_permissions(
_: bool = Depends(require_access("modules.view")),
db: Session = Depends(get_db)
):
# Now using the Service method you just shared!
return ModulePermissionService.get_module_permissions(db, module_id)
@router.post("/{module_id}/permissions/sync")
-7
View File
@@ -47,9 +47,6 @@ def subscription_notice_summary(
"""
since = datetime.now(timezone.utc) - timedelta(days=days)
# Unscoped throughout: this is a platform-wide view by definition, and the
# dependency above has already established that the caller is entitled to
# one.
with unscoped():
by_kind = dict(
db.query(SubscriptionNotice.kind, func.count(SubscriptionNotice.id))
@@ -67,9 +64,6 @@ def subscription_notice_summary(
.count()
)
# Workspaces that will hit the same problem next time. Counted from the
# workspaces rather than from past notices, so it is a forecast rather
# than a history.
no_billing_contact = (
db.query(Tenant)
.filter(Tenant.end_date.isnot(None), Tenant.billing_email.is_(None))
@@ -261,7 +255,6 @@ def locked_accounts(
{"tenant_id": str(tid) if tid else None, "locked": count}
for tid, count in by_tenant.items()
],
# Named so the caller knows the list is short, not the problem.
"listed": min(len(users), 50),
"truncated": len(users) > 50,
"accounts": [
-3
View File
@@ -95,9 +95,6 @@ def issue(
AuditLogService.log(
db=db, module_name="Security", action_type="CREATE",
entity_id=str(key.id), entity_name=key.name,
# The prefix, never the key. This entry is meant to be readable by
# anybody investigating, which is precisely why it cannot hold the
# credential.
description="API key '" + key.name + "' issued (" + key.prefix + ")",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
-11
View File
@@ -72,9 +72,6 @@ def signin(
response: Response,
db: Session = Depends(get_db),
):
# Recorded on the session so the list means something to whoever reads it.
# "Chrome on Windows, from this address, two minutes ago" is reviewable; a
# row of opaque identifiers is not.
result = AuthController.signin(
db,
signin_data,
@@ -172,14 +169,6 @@ def reset_password_with_otp(
return AuthController.reset_password_with_otp(db, request)
# --------------------------------------------------------------------- sessions
#
# Rotation and revocation already worked; what was missing was any way to see or
# use them. An account holder could not tell where they were signed in, and had
# no way to end a session they no longer recognised without changing their
# password and hoping.
@router.get("/sessions")
def list_sessions(
request: Request,
-12
View File
@@ -67,9 +67,6 @@ def _own(db: Session, provider_id: uuid.UUID, actor: User) -> IdentityProvider:
return provider
# ------------------------------------------------------------------- admin
@router.get("/", response_model=List[IdentityProviderResponse])
def list_providers(
db: Session = Depends(get_db),
@@ -143,13 +140,9 @@ def update_provider(
provider = _own(db, provider_id, current_user)
changes = data.model_dump(exclude_unset=True)
# Omitted means "leave it"; the API cannot return it, so an edit form has
# nothing to send back.
if "client_secret" in changes:
provider.client_secret = changes.pop("client_secret")
# Changing where the provider lives invalidates what was discovered from the
# old one, and leaving stale endpoints behind points logins at the wrong place.
if "issuer" in changes and changes["issuer"] != provider.issuer:
provider.authorization_endpoint = None
provider.token_endpoint = None
@@ -224,9 +217,6 @@ def delete_provider(
return {"message": "Identity provider removed"}
# ------------------------------------------------------------------ public
@public_router.get("/{tenant_domain}/providers", response_model=List[PublicProvider])
def public_providers(tenant_domain: str, db: Session = Depends(get_db)):
"""What a signed-out login page may know: a name and a slug.
@@ -241,8 +231,6 @@ def public_providers(tenant_domain: str, db: Session = Depends(get_db)):
db.query(Tenant).filter(Tenant.tenant_domain == tenant_domain).first()
)
if tenant is None:
# An empty list rather than a 404: whether a domain is a customer is
# not a signed-out visitor's business.
return []
providers = (
-6
View File
@@ -40,9 +40,6 @@ from app.services.system.audit_log_service import AuditLogService
router = APIRouter()
public_router = APIRouter()
# Accepting is a write that creates an account, and the token is the only thing
# guarding it. Also keyed on the token itself, so an attacker spreading guesses
# across many source addresses is still capped per token.
ACCEPT_LIMIT = rate_limit("invitation-accept", limit=10, window_seconds=900,
by_body_field="token")
PREVIEW_LIMIT = rate_limit("invitation-preview", limit=30, window_seconds=900)
@@ -277,9 +274,6 @@ def accept(
db=db, module_name="Users", action_type="CREATE",
entity_id=str(user.id), entity_name=user.email,
description="Invitation accepted by '" + user.email + "'",
# The new account is the actor. There is no session yet, but attributing
# this to nobody would leave the one entry that explains where an
# account came from without a subject.
performed_by_id=str(user.id),
performed_by_email=user.email,
ip_address=get_client_ip(request),
-3
View File
@@ -151,9 +151,6 @@ def disable_my_factor(
factor is the step an attacker takes before they settle in."""
_reauthenticate(db, current_user, payload)
mfa_service.disable(db, current_user)
# Removing a factor is the step an attacker takes before settling in, so the
# owner is told even though they are the one who just did it — a notice they
# did not expect is the signal.
notification_service.notify(
db, user_id=current_user.id, tenant_id=current_user.tenant_id,
kind=notification_service.MFA_DISABLED,
-17
View File
@@ -37,8 +37,6 @@ class OrgUnitUpdate(BaseModel):
class OrgUnitMove(BaseModel):
# Explicitly nullable: moving a unit to the top level is a real operation,
# and `null` has to mean that rather than "leave it alone".
parent_id: Optional[uuid.UUID] = None
@@ -52,9 +50,6 @@ class OrgUnitResponse(BaseModel):
path: str
depth: int
is_active: bool
# Both travel with the unit because a screen that lists branches needs them
# per row. `seat_limit` absent means unconstrained rather than zero — a unit
# with no allocation is bounded only by the workspace.
seat_limit: Optional[int] = None
seats_used: int = 0
@@ -117,9 +112,6 @@ def _to_response(unit: OrgUnit) -> OrgUnitResponse:
return payload
# ------------------------------------------------------------- structure
@router.get("", response_model=List[OrgUnitResponse])
def list_units(
db: Session = Depends(get_db),
@@ -136,7 +128,6 @@ def list_units(
tenant_id = _workspace(current_user)
rows = units.tree(db, tenant_id)
# Two queries for the whole tree rather than two per unit.
limits = seat_allocation_service.limits_by_unit(db, tenant_id)
used = seat_allocation_service.usage_by_unit(db, tenant_id)
@@ -254,12 +245,7 @@ def delete_unit(
db.commit()
# ------------------------------------------------------------- membership
class SeatAllocationRequest(BaseModel):
# Nullable clears the allocation, which is not the same as zero: zero means
# nobody may be in this unit, absent means it has no cap of its own.
seat_limit: Optional[int] = Field(default=None, ge=0)
@@ -387,9 +373,6 @@ def remove_member(
db.commit()
# ------------------------------------------------------------ admin scope
@router.post("/{unit_id}/administrators", status_code=status.HTTP_204_NO_CONTENT)
def grant_scope(
unit_id: uuid.UUID,
-15
View File
@@ -44,13 +44,6 @@ def _workspace(user: User):
return user.tenant_id
# ------------------------------------------------------------- discovery
#
# Azure AD fetches all three before it will do anything, and reports "endpoint
# not compliant" rather than naming the missing document — so an implementation
# without them looks broken in a way that takes a day to diagnose.
@router.get("/ServiceProviderConfig")
def service_provider_config(request: Request,
_=Depends(get_current_user)):
@@ -58,8 +51,6 @@ def service_provider_config(request: Request,
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
"documentationUri": "https://example.com/docs/scim",
"patch": {"supported": True},
# Honest rather than aspirational. A client told bulk is supported will
# use it, and a 404 mid-sync is worse than never offering it.
"bulk": {"supported": False, "maxOperations": 0, "maxPayloadSize": 0},
"filter": {"supported": True, "maxResults": scim.MAX_PAGE_SIZE},
"changePassword": {"supported": False},
@@ -121,9 +112,6 @@ def schemas(request: Request,
})
# ----------------------------------------------------------------- users
@router.get("/Users")
def list_users(
request: Request,
@@ -213,9 +201,6 @@ def delete_user(
return Response(status_code=status.HTTP_204_NO_CONTENT)
# ---------------------------------------------------------------- groups
@router.get("/Groups")
def list_groups(
request: Request,
+1 -3
View File
@@ -39,7 +39,5 @@ def exchange_grant(
payload=payload,
x_module_signature=x_module_signature,
x_module_key=x_module_key,
# Passed wholesale rather than as three more named parameters: the
# replay headers are optional and the set will grow.
raw_headers={k.lower(): v for k, v in request.headers.items()},
)
)
+1 -9
View File
@@ -45,7 +45,6 @@ def get_all_tenants(
current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.tenant.read"))
):
# READ actions are typically not logged to avoid DB bloat
return TenantController.get_all_tenants(db)
@router.get("/me", response_model=TenantResponse)
@@ -84,7 +83,6 @@ def update_tenant(
current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.tenant.update"))
):
# 1. Get snapshot BEFORE update for Audit Log
existing = TenantController.get_tenant_by_id(db, tenant_id)
old_snapshot = {
"tenant_name": existing.tenant_name,
@@ -97,16 +95,13 @@ def update_tenant(
"status": existing.status,
}
# 2. Perform update
result = TenantController.update_tenant(db, tenant_id, tenant_data, actor=current_user)
# 3. Prepare new values and indentify deltas
new_snapshot = tenant_data.model_dump(mode='json', exclude_unset=True)
old_values = {k: old_snapshot[k] for k in new_snapshot if k in old_snapshot and old_snapshot[k] != new_snapshot[k]}
new_values = {k: new_snapshot[k] for k in old_values}
# 4. Log the change
AuditLogService.log(
db=db,
module_name="Tenants",
@@ -131,13 +126,10 @@ def delete_tenant(
current_user: User = Depends(get_current_user),
_ = Depends(require_access("superadmin.tenant.delete"))
):
# 1. Get snapshot before deletion
existing = TenantController.get_tenant_by_id(db, tenant_id)
# 2. Perform deletion
result = TenantController.delete_tenant(db, tenant_id)
# 3. Log the deletion
AuditLogService.log(
db=db,
module_name="Tenants",
@@ -181,4 +173,4 @@ def list_tenants(
statuses=statuses,
sort_by=sort_by,
sort_order=sort_order,
)
)
-2
View File
@@ -21,7 +21,5 @@ def jwks(response: Response):
this on every request would make the platform part of its hot path.
"""
document = module_identity.jwks()
# Short enough that a rotation propagates within the hour, long enough that
# this is not a request per token verification.
response.headers["Cache-Control"] = "public, max-age=3600"
return document
-17
View File
@@ -65,9 +65,6 @@ class DocumentResponse(BaseModel):
description: Optional[str] = None
uploaded_by_id: Optional[uuid.UUID] = None
created_at: datetime
# Not the storage key. It is not a secret in the sense that knowing it grants
# anything — every read is checked — but it is an internal detail that only
# invites somebody to try building a URL out of it.
class StorageUsage(BaseModel):
@@ -140,7 +137,6 @@ def upload(
tenant_id=_workspace(current_user),
uploaded_by=current_user,
filename=file.filename or "upload",
# The raw stream, so nothing has to hold the whole file in memory.
source=file.file,
entity_type=entity_type,
entity_id=entity_id,
@@ -158,8 +154,6 @@ def upload(
new_values={
"content_type": document.content_type,
"size_bytes": document.size_bytes,
# The checksum, not the key: it is the thing that identifies the
# content afterwards, and the one worth having in an investigation.
"checksum": document.checksum,
},
)
@@ -178,10 +172,6 @@ def download(
document = document_service.get(db, _workspace(current_user), document_id)
if not document_storage.exists(document.storage_key):
# The row promises a file that is not there. A filesystem is not
# transactional, so this is possible after a crash or a restore that
# brought back a database without its storage — and 404 is a truer
# answer than a stack trace.
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail="That file is no longer available")
@@ -194,15 +184,8 @@ def download(
headers={
"Content-Disposition": f'{disposition}; filename="{safe_name}"',
"Content-Length": str(document.size_bytes),
# A browser must not go looking for a better idea than the type we
# gave it: without this, text containing HTML is rendered as HTML by
# some of them, from this origin.
"X-Content-Type-Options": "nosniff",
# Belt to that braces. Even a wrong type has no origin to attack
# from inside a sandbox.
"Content-Security-Policy": "sandbox; default-src 'none'",
# Private, because the response is workspace-scoped and a shared
# cache holding it would serve one customer's file to another.
"Cache-Control": "private, max-age=0, no-store",
},
)
-3
View File
@@ -37,7 +37,6 @@ class ListResponse(BaseModel):
name: str
description: Optional[str] = None
allows_custom_items: bool
# True when the platform owns it: visible to you, not yours to change.
is_platform: bool = False
@@ -60,8 +59,6 @@ class ListCreate(BaseModel):
class ListUpdate(BaseModel):
# No `code`. It is what integrations name and stored records point at, so
# renaming one is a silent data migration disguised as an edit.
name: Optional[str] = Field(default=None, max_length=150)
description: Optional[str] = Field(default=None, max_length=500)
-4
View File
@@ -39,8 +39,6 @@ class NotificationResponse(BaseModel):
class NotificationList(BaseModel):
items: List[NotificationResponse]
# The number the bell icon shows. Returned with the list so a client does not
# need a second request to render one component.
unread: int
@@ -76,8 +74,6 @@ def mark_read(
current_user: User = Depends(get_current_user),
):
if not notification_service.mark_read(db, current_user, notification_id):
# 404 whether it belongs to somebody else, does not exist, or was
# already read. The first two must not be distinguishable.
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail="No such unread notification")
db.commit()
-2
View File
@@ -28,8 +28,6 @@ class EmailSettingsPayload(BaseModel):
smtp_host: str = Field(min_length=1, max_length=255)
smtp_port: int = Field(default=587, ge=1, le=65535)
smtp_user: Optional[str] = Field(default=None, max_length=255)
# Omitted leaves the stored password alone; there is no way to read it back,
# so an edit form cannot round-trip it.
smtp_password: Optional[str] = None
use_ssl: bool = False
from_address: EmailStr
+1 -5
View File
@@ -174,10 +174,6 @@ def update(
if payload.is_active is not None:
endpoint.is_active = payload.is_active
if payload.is_active:
# Re-enabling clears the count as well. Otherwise an endpoint that
# was switched off after twenty failures is one failure away from
# being switched off again, and the customer's fix never gets a
# chance to prove itself.
endpoint.consecutive_failures = 0
endpoint.disabled_reason = None
@@ -270,7 +266,7 @@ def deliveries(
The single most useful screen when a customer says "we never got it" — the
alternative is asking them to trust that we tried.
"""
_own(db, endpoint_id, current_user) # 404s if it is not theirs
_own(db, endpoint_id, current_user)
query = db.query(WebhookDelivery).filter(
WebhookDelivery.endpoint_id == endpoint_id
-6
View File
@@ -17,8 +17,6 @@ from pydantic import BaseModel, ConfigDict, Field
class ApiKeyCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
# Empty means "whatever the issuing user can do". Anything listed must be
# something they already have — a key cannot be used to grant yourself more.
scopes: List[str] = Field(default_factory=list)
expires_in_days: Optional[int] = Field(default=None, ge=1, le=3650)
@@ -28,8 +26,6 @@ class ApiKeyResponse(BaseModel):
id: uuid.UUID
name: str
# The visible half. Enough to recognise a key in a log or a list; useless as
# a credential on its own.
prefix: str
scopes: List[str]
user_id: uuid.UUID
@@ -42,8 +38,6 @@ class ApiKeyResponse(BaseModel):
class ApiKeyCreated(BaseModel):
api_key: ApiKeyResponse
# Shown once and never again. Said plainly here because a client that treats
# this like any other field will store it somewhere it should not be.
key: str
-12
View File
@@ -29,9 +29,6 @@ class UserSignin(BaseModel):
email: EmailStr
password: str
remember_me: bool = False
# Absent on the first attempt: the client cannot know a factor is
# required until the platform says so, which is what the 401 with
# X-MFA-Required is for.
mfa_code: Optional[str] = None
class AccessInRole(BaseModel):
@@ -47,9 +44,6 @@ class RoleInUser(BaseModel):
class UserResponse(UserBase):
id: uuid.UUID
# Was absent, so /me never returned it and the client's
# `userData.preferred_language` was always undefined — the language the user
# picked could never be applied on bootstrap.
preferred_language: Optional[str] = None
is_superadmin: bool = False
tenant_id: Optional[uuid.UUID] = None
@@ -74,17 +68,11 @@ class UserUpdate(BaseModel):
which are access-gated and workspace-scoped.
"""
# Reject unknown fields rather than dropping them. Pydantic's default is to
# ignore extras, which meant a request sending `status` got a 200 and no
# change — indistinguishable, from the caller's side, from having worked.
# A caller who thinks they changed something should be told they did not.
model_config = ConfigDict(extra="forbid")
first_name: Optional[str] = None
last_name: Optional[str] = None
phone_number: Optional[str] = None
# Was missing, so UserUpdate(preferred_language=...) was silently discarded by
# Pydantic and the language endpoint wrote nothing at all.
preferred_language: Optional[str] = None
class TokenResponse(BaseModel):
@@ -20,8 +20,6 @@ class IdentityProviderBase(BaseModel):
@field_validator("issuer")
@classmethod
def issuer_must_be_https(cls, value: Optional[str]) -> Optional[str]:
# Checked here as well as at fetch time so the message lands next to the
# field rather than arriving as a failed discovery a minute later.
if value and not value.startswith("https://"):
raise ValueError("issuer must be an https URL")
return value.rstrip("/") if value else value
@@ -37,8 +35,6 @@ class IdentityProviderUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=2, max_length=150)
issuer: Optional[str] = Field(None, max_length=500)
client_id: Optional[str] = Field(None, max_length=255)
# Omitted leaves the stored secret alone; there is no way to read it back, so
# an edit form cannot round-trip it.
client_secret: Optional[str] = None
scopes: Optional[str] = None
allowed_domains: Optional[str] = None
-5
View File
@@ -39,16 +39,11 @@ class InvitationResponse(BaseModel):
accepted_at: Optional[datetime] = None
revoked_at: Optional[datetime] = None
created_at: datetime
# Derived rather than stored: "expired" is a fact about the clock, and a
# column would go stale the moment it was written.
state: str
class InvitationCreated(BaseModel):
invitation: InvitationResponse
# Returned so an administrator can pass the link on by hand when mail does
# not arrive — which is common enough that leaving it out means keeping a
# second copy of the token somewhere worse.
acceptance_url: str
email_sent: bool
-2
View File
@@ -41,6 +41,4 @@ class MfaPasswordConfirm(BaseModel):
"""
password: str = Field(min_length=1)
# Optional so somebody who has lost their phone can still get out with a
# recovery code rather than a support ticket.
code: Optional[str] = None
@@ -18,8 +18,6 @@ class SubscriptionHistoryEntry(BaseModel):
to_end_date: Optional[date] = None
from_status: Optional[str] = None
to_status: Optional[str] = None
# The email is kept alongside the id so a deleted account does not erase who
# made the change.
changed_by_email: Optional[str] = None
notes: Optional[str] = None
created_at: datetime

Some files were not shown because too many files have changed in this diff Show More