diff --git a/alembic/env.py b/alembic/env.py index f22101c..2cf1fcb 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -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. diff --git a/alembic/versions/03a1b1f05e99_create_event_log_model.py b/alembic/versions/03a1b1f05e99_create_event_log_model.py index ade9a1f..4f54861 100644 --- a/alembic/versions/03a1b1f05e99_create_event_log_model.py +++ b/alembic/versions/03a1b1f05e99_create_event_log_model.py @@ -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 ### diff --git a/alembic/versions/5f2e9c1a7b44_add_tenant_lifecycle_fields.py b/alembic/versions/5f2e9c1a7b44_add_tenant_lifecycle_fields.py index 9b96001..4409e22 100644 --- a/alembic/versions/5f2e9c1a7b44_add_tenant_lifecycle_fields.py +++ b/alembic/versions/5f2e9c1a7b44_add_tenant_lifecycle_fields.py @@ -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 diff --git a/alembic/versions/63b95ea5b967_split_module_access_table.py b/alembic/versions/63b95ea5b967_split_module_access_table.py index 918879a..1093fad 100644 --- a/alembic/versions/63b95ea5b967_split_module_access_table.py +++ b/alembic/versions/63b95ea5b967_split_module_access_table.py @@ -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 ### diff --git a/alembic/versions/6a1b2c3d4e55_add_duration_days_to_subscription_plans.py b/alembic/versions/6a1b2c3d4e55_add_duration_days_to_subscription_plans.py index b0f544a..9670ece 100644 --- a/alembic/versions/6a1b2c3d4e55_add_duration_days_to_subscription_plans.py +++ b/alembic/versions/6a1b2c3d4e55_add_duration_days_to_subscription_plans.py @@ -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 diff --git a/alembic/versions/720027c97104_add_follow_up_event_to_event_logs.py b/alembic/versions/720027c97104_add_follow_up_event_to_event_logs.py index 0c6ae16..b041db8 100644 --- a/alembic/versions/720027c97104_add_follow_up_event_to_event_logs.py +++ b/alembic/versions/720027c97104_add_follow_up_event_to_event_logs.py @@ -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 ### diff --git a/alembic/versions/73b754d5b2c5_create_audit_logs_table.py b/alembic/versions/73b754d5b2c5_create_audit_logs_table.py index a010b57..71a7760 100644 --- a/alembic/versions/73b754d5b2c5_create_audit_logs_table.py +++ b/alembic/versions/73b754d5b2c5_create_audit_logs_table.py @@ -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 ### diff --git a/alembic/versions/74b6ccfaee8e_add_module_registry.py b/alembic/versions/74b6ccfaee8e_add_module_registry.py index 295e551..cda91f4 100644 --- a/alembic/versions/74b6ccfaee8e_add_module_registry.py +++ b/alembic/versions/74b6ccfaee8e_add_module_registry.py @@ -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 ### diff --git a/alembic/versions/7b2c4d5e6f77_add_max_users_allowed_to_subscription_plans.py b/alembic/versions/7b2c4d5e6f77_add_max_users_allowed_to_subscription_plans.py index 7c24d5d..463c0a3 100644 --- a/alembic/versions/7b2c4d5e6f77_add_max_users_allowed_to_subscription_plans.py +++ b/alembic/versions/7b2c4d5e6f77_add_max_users_allowed_to_subscription_plans.py @@ -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 diff --git a/alembic/versions/88cfc7dee19d_scoped_access_code_uniqueness.py b/alembic/versions/88cfc7dee19d_scoped_access_code_uniqueness.py index 73deead..b425613 100644 --- a/alembic/versions/88cfc7dee19d_scoped_access_code_uniqueness.py +++ b/alembic/versions/88cfc7dee19d_scoped_access_code_uniqueness.py @@ -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 ### diff --git a/alembic/versions/8acd83604252_initial_migration.py b/alembic/versions/8acd83604252_initial_migration.py index e1d1d5f..57fdc0b 100644 --- a/alembic/versions/8acd83604252_initial_migration.py +++ b/alembic/versions/8acd83604252_initial_migration.py @@ -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 ### diff --git a/alembic/versions/91cc93992a91_add_parent_id_to_module_access.py b/alembic/versions/91cc93992a91_add_parent_id_to_module_access.py index 98390f2..8d3b259 100644 --- a/alembic/versions/91cc93992a91_add_parent_id_to_module_access.py +++ b/alembic/versions/91cc93992a91_add_parent_id_to_module_access.py @@ -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 ### diff --git a/alembic/versions/9283c3f52a76_add_subscription_model.py b/alembic/versions/9283c3f52a76_add_subscription_model.py index d54944e..fa61189 100644 --- a/alembic/versions/9283c3f52a76_add_subscription_model.py +++ b/alembic/versions/9283c3f52a76_add_subscription_model.py @@ -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 ### diff --git a/alembic/versions/a1d4f7c3e95b_org_unit_seats.py b/alembic/versions/a1d4f7c3e95b_org_unit_seats.py index 43f270d..213aa23 100644 --- a/alembic/versions/a1d4f7c3e95b_org_unit_seats.py +++ b/alembic/versions/a1d4f7c3e95b_org_unit_seats.py @@ -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", diff --git a/alembic/versions/a1f0c2d3e401_explicit_superadmin_flag.py b/alembic/versions/a1f0c2d3e401_explicit_superadmin_flag.py index 29c04d6..93e2605 100644 --- a/alembic/versions/a1f0c2d3e401_explicit_superadmin_flag.py +++ b/alembic/versions/a1f0c2d3e401_explicit_superadmin_flag.py @@ -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 diff --git a/alembic/versions/a4d7f2c9e63b_org_units.py b/alembic/versions/a4d7f2c9e63b_org_units.py index 72d4423..0096a7e 100644 --- a/alembic/versions/a4d7f2c9e63b_org_units.py +++ b/alembic/versions/a4d7f2c9e63b_org_units.py @@ -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" diff --git a/alembic/versions/a7c9e3f5b205_subscription_notices.py b/alembic/versions/a7c9e3f5b205_subscription_notices.py index 26d2598..ee960e6 100644 --- a/alembic/versions/a7c9e3f5b205_subscription_notices.py +++ b/alembic/versions/a7c9e3f5b205_subscription_notices.py @@ -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", diff --git a/alembic/versions/a7d9f1c3e80b_api_keys.py b/alembic/versions/a7d9f1c3e80b_api_keys.py index 3ef8bdb..5db7bef 100644 --- a/alembic/versions/a7d9f1c3e80b_api_keys.py +++ b/alembic/versions/a7d9f1c3e80b_api_keys.py @@ -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"), diff --git a/alembic/versions/b5e8c2a7f31d_soft_delete_workspaces_and_units.py b/alembic/versions/b5e8c2a7f31d_soft_delete_workspaces_and_units.py index 915a995..ca9956e 100644 --- a/alembic/versions/b5e8c2a7f31d_soft_delete_workspaces_and_units.py +++ b/alembic/versions/b5e8c2a7f31d_soft_delete_workspaces_and_units.py @@ -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"), diff --git a/alembic/versions/b6e3a1d9f42c_tenant_email_settings.py b/alembic/versions/b6e3a1d9f42c_tenant_email_settings.py index 92e9c5f..cade332 100644 --- a/alembic/versions/b6e3a1d9f42c_tenant_email_settings.py +++ b/alembic/versions/b6e3a1d9f42c_tenant_email_settings.py @@ -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), diff --git a/alembic/versions/b8d1f4a6c306_alert_state.py b/alembic/versions/b8d1f4a6c306_alert_state.py index 466b1a8..6dcc178 100644 --- a/alembic/versions/b8d1f4a6c306_alert_state.py +++ b/alembic/versions/b8d1f4a6c306_alert_state.py @@ -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"]) diff --git a/alembic/versions/b8e1c4a6f90c_customer_webhooks.py b/alembic/versions/b8e1c4a6f90c_customer_webhooks.py index 0cef49a..d8715d7 100644 --- a/alembic/versions/b8e1c4a6f90c_customer_webhooks.py +++ b/alembic/versions/b8e1c4a6f90c_customer_webhooks.py @@ -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"], diff --git a/alembic/versions/c37ba6143f83_add_provisioning_endpoint_to_module_.py b/alembic/versions/c37ba6143f83_add_provisioning_endpoint_to_module_.py index b7d4693..0b11046 100644 --- a/alembic/versions/c37ba6143f83_add_provisioning_endpoint_to_module_.py +++ b/alembic/versions/c37ba6143f83_add_provisioning_endpoint_to_module_.py @@ -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 ### diff --git a/alembic/versions/c3d5e7f9a801_row_level_security.py b/alembic/versions/c3d5e7f9a801_row_level_security.py index 80317bd..978ad13 100644 --- a/alembic/versions/c3d5e7f9a801_row_level_security.py +++ b/alembic/versions/c3d5e7f9a801_row_level_security.py @@ -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} diff --git a/alembic/versions/c8f1b4e7a03d_documents.py b/alembic/versions/c8f1b4e7a03d_documents.py index acb1a15..d1d1975 100644 --- a/alembic/versions/c8f1b4e7a03d_documents.py +++ b/alembic/versions/c8f1b4e7a03d_documents.py @@ -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"), diff --git a/alembic/versions/c9e2a4b6d407_case_insensitive_email.py b/alembic/versions/c9e2a4b6d407_case_insensitive_email.py index fbc736a..cdba34c 100644 --- a/alembic/versions/c9e2a4b6d407_case_insensitive_email.py +++ b/alembic/versions/c9e2a4b6d407_case_insensitive_email.py @@ -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") diff --git a/alembic/versions/c9f2b5d7e10d_idempotency_records.py b/alembic/versions/c9f2b5d7e10d_idempotency_records.py index 660646e..90b808b 100644 --- a/alembic/versions/c9f2b5d7e10d_idempotency_records.py +++ b/alembic/versions/c9f2b5d7e10d_idempotency_records.py @@ -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 diff --git a/alembic/versions/cd8ba77ffd9e_make_sso_grants_stateless.py b/alembic/versions/cd8ba77ffd9e_make_sso_grants_stateless.py index 7a33587..42a2e22 100644 --- a/alembic/versions/cd8ba77ffd9e_make_sso_grants_stateless.py +++ b/alembic/versions/cd8ba77ffd9e_make_sso_grants_stateless.py @@ -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 ### diff --git a/alembic/versions/d1a3c5e7f508_identity_providers.py b/alembic/versions/d1a3c5e7f508_identity_providers.py index 06f35a8..08247a4 100644 --- a/alembic/versions/d1a3c5e7f508_identity_providers.py +++ b/alembic/versions/d1a3c5e7f508_identity_providers.py @@ -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: diff --git a/alembic/versions/d1a4c8f2b30e_notifications.py b/alembic/versions/d1a4c8f2b30e_notifications.py index d5a585e..c8967c2 100644 --- a/alembic/versions/d1a4c8f2b30e_notifications.py +++ b/alembic/versions/d1a4c8f2b30e_notifications.py @@ -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"), diff --git a/alembic/versions/d4a7c2f8b51e_master_data.py b/alembic/versions/d4a7c2f8b51e_master_data.py index 8791eac..a9e6758 100644 --- a/alembic/versions/d4a7c2f8b51e_master_data.py +++ b/alembic/versions/d4a7c2f8b51e_master_data.py @@ -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} diff --git a/alembic/versions/d4f6a8b0c902_subscription_lifecycle.py b/alembic/versions/d4f6a8b0c902_subscription_lifecycle.py index 3f3aed4..8284ebf 100644 --- a/alembic/versions/d4f6a8b0c902_subscription_lifecycle.py +++ b/alembic/versions/d4f6a8b0c902_subscription_lifecycle.py @@ -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( diff --git a/alembic/versions/e2b5d9a3c41f_soft_delete_users.py b/alembic/versions/e2b5d9a3c41f_soft_delete_users.py index 4bdd186..c9c9e9b 100644 --- a/alembic/versions/e2b5d9a3c41f_soft_delete_users.py +++ b/alembic/versions/e2b5d9a3c41f_soft_delete_users.py @@ -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"), diff --git a/alembic/versions/e3b5d7a9c609_mfa_and_lockout.py b/alembic/versions/e3b5d7a9c609_mfa_and_lockout.py index 5369535..203be22 100644 --- a/alembic/versions/e3b5d7a9c609_mfa_and_lockout.py +++ b/alembic/versions/e3b5d7a9c609_mfa_and_lockout.py @@ -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} diff --git a/alembic/versions/e5a7b9c1d003_user_sessions.py b/alembic/versions/e5a7b9c1d003_user_sessions.py index 53dcf2a..54cd5e6 100644 --- a/alembic/versions/e5a7b9c1d003_user_sessions.py +++ b/alembic/versions/e5a7b9c1d003_user_sessions.py @@ -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") diff --git a/alembic/versions/e7b2d5c9a14f_audit_retention_index.py b/alembic/versions/e7b2d5c9a14f_audit_retention_index.py index 9d65538..cccd80e 100644 --- a/alembic/versions/e7b2d5c9a14f_audit_retention_index.py +++ b/alembic/versions/e7b2d5c9a14f_audit_retention_index.py @@ -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"] ) diff --git a/alembic/versions/f3c6e1b8d52a_trigram_user_search.py b/alembic/versions/f3c6e1b8d52a_trigram_user_search.py index be2076a..75a7790 100644 --- a/alembic/versions/f3c6e1b8d52a_trigram_user_search.py +++ b/alembic/versions/f3c6e1b8d52a_trigram_user_search.py @@ -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. diff --git a/alembic/versions/f5c7e9b1d70a_user_invitations.py b/alembic/versions/f5c7e9b1d70a_user_invitations.py index 33d1100..91b784b 100644 --- a/alembic/versions/f5c7e9b1d70a_user_invitations.py +++ b/alembic/versions/f5c7e9b1d70a_user_invitations.py @@ -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 diff --git a/alembic/versions/f6b8c2d4e104_scope_audit_logs.py b/alembic/versions/f6b8c2d4e104_scope_audit_logs.py index 7ba3049..c771c5f 100644 --- a/alembic/versions/f6b8c2d4e104_scope_audit_logs.py +++ b/alembic/versions/f6b8c2d4e104_scope_audit_logs.py @@ -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( diff --git a/alembic/versions/f8c3a6e1b72d_notification_preferences.py b/alembic/versions/f8c3a6e1b72d_notification_preferences.py index 7b5a309..3e1b6f2 100644 --- a/alembic/versions/f8c3a6e1b72d_notification_preferences.py +++ b/alembic/versions/f8c3a6e1b72d_notification_preferences.py @@ -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"], diff --git a/alembic/versions/f9cf173f48f9_add_audit_log_extra_columns.py b/alembic/versions/f9cf173f48f9_add_audit_log_extra_columns.py index a80d8bf..437ca44 100644 --- a/alembic/versions/f9cf173f48f9_add_audit_log_extra_columns.py +++ b/alembic/versions/f9cf173f48f9_add_audit_log_extra_columns.py @@ -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 ### diff --git a/app/__init__.py b/app/__init__.py index 5e67956..64513b3 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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 \ No newline at end of file + return app diff --git a/app/config/database.py b/app/config/database.py index 19d4893..485bb6a 100644 --- a/app/config/database.py +++ b/app/config/database.py @@ -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() \ No newline at end of file + db.close() diff --git a/app/config/security.py b/app/config/security.py index 310e824..bdbc124 100644 --- a/app/config/security.py +++ b/app/config/security.py @@ -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() \ No newline at end of file +security = SecurityUtils() diff --git a/app/config/settings.py b/app/config/settings.py index 1ce1027..f540dc1 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -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.`, 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() \ No newline at end of file +settings = Settings() diff --git a/app/controllers/auth/role_controller.py b/app/controllers/auth/role_controller.py index 86a2b52..3f79367 100644 --- a/app/controllers/auth/role_controller.py +++ b/app/controllers/auth/role_controller.py @@ -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, - ) \ No newline at end of file + ) diff --git a/app/controllers/auth/sso_controller.py b/app/controllers/auth/sso_controller.py index 4266953..237948e 100644 --- a/app/controllers/auth/sso_controller.py +++ b/app/controllers/auth/sso_controller.py @@ -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 - ) \ No newline at end of file + ) diff --git a/app/controllers/auth/user_controller.py b/app/controllers/auth/user_controller.py index bb6e3cd..5b104e0 100644 --- a/app/controllers/auth/user_controller.py +++ b/app/controllers/auth/user_controller.py @@ -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, - ) \ No newline at end of file + ) diff --git a/app/core/crypto.py b/app/core/crypto.py index 0bebb87..45b7fbb 100644 --- a/app/core/crypto.py +++ b/app/core/crypto.py @@ -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)) diff --git a/app/core/document_storage.py b/app/core/document_storage.py index 81c73ff..350f0b4 100644 --- a/app/core/document_storage.py +++ b/app/core/document_storage.py @@ -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 diff --git a/app/core/file_types.py b/app/core/file_types.py index 46fa3de..9725946 100644 --- a/app/core/file_types.py +++ b/app/core/file_types.py @@ -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" diff --git a/app/core/rls.py b/app/core/rls.py index f9dc958..6e4a9b1 100644 --- a/app/core/rls.py +++ b/app/core/rls.py @@ -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") diff --git a/app/core/ssrf.py b/app/core/ssrf.py index f6f69c3..1992c51 100644 --- a/app/core/ssrf.py +++ b/app/core/ssrf.py @@ -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 diff --git a/app/middleware/auth_middleware.py b/app/middleware/auth_middleware.py index 906279c..9d63a21 100644 --- a/app/middleware/auth_middleware.py +++ b/app/middleware/auth_middleware.py @@ -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 diff --git a/app/middleware/idempotency_middleware.py b/app/middleware/idempotency_middleware.py index 60df0d4..fb756e8 100644 --- a/app/middleware/idempotency_middleware.py +++ b/app/middleware/idempotency_middleware.py @@ -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, diff --git a/app/middleware/rate_limit.py b/app/middleware/rate_limit.py index 084aa41..6d3a8f6 100644 --- a/app/middleware/rate_limit.py +++ b/app/middleware/rate_limit.py @@ -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( diff --git a/app/middleware/tenant_scope_middleware.py b/app/middleware/tenant_scope_middleware.py index 3bf4806..b5e6189 100644 --- a/app/middleware/tenant_scope_middleware.py +++ b/app/middleware/tenant_scope_middleware.py @@ -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) diff --git a/app/models/auth/identity_model.py b/app/models/auth/identity_model.py index 6b1e8ae..a942765 100644 --- a/app/models/auth/identity_model.py +++ b/app/models/auth/identity_model.py @@ -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) diff --git a/app/models/auth/module_environment_model.py b/app/models/auth/module_environment_model.py index a4cf1f2..c2be223 100644 --- a/app/models/auth/module_environment_model.py +++ b/app/models/auth/module_environment_model.py @@ -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"" \ No newline at end of file + return f"" diff --git a/app/models/auth/org_unit_model.py b/app/models/auth/org_unit_model.py index 50c30e2..c5419d1 100644 --- a/app/models/auth/org_unit_model.py +++ b/app/models/auth/org_unit_model.py @@ -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 diff --git a/app/models/auth/role_access_model.py b/app/models/auth/role_access_model.py index c64dbbe..8c833f9 100644 --- a/app/models/auth/role_access_model.py +++ b/app/models/auth/role_access_model.py @@ -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") diff --git a/app/models/auth/seat_allocation_model.py b/app/models/auth/seat_allocation_model.py index 4d71408..017d78e 100644 --- a/app/models/auth/seat_allocation_model.py +++ b/app/models/auth/seat_allocation_model.py @@ -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, diff --git a/app/models/auth/sso_grant_model.py b/app/models/auth/sso_grant_model.py index 06445e1..5a597db 100644 --- a/app/models/auth/sso_grant_model.py +++ b/app/models/auth/sso_grant_model.py @@ -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()) diff --git a/app/models/auth/subscription_plan_model.py b/app/models/auth/subscription_plan_model.py index f9a13e4..977a0f3 100644 --- a/app/models/auth/subscription_plan_model.py +++ b/app/models/auth/subscription_plan_model.py @@ -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") diff --git a/app/models/auth/tenant_model.py b/app/models/auth/tenant_model.py index 42cdf44..df5b038 100644 --- a/app/models/auth/tenant_model.py +++ b/app/models/auth/tenant_model.py @@ -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"" \ No newline at end of file + return f"" diff --git a/app/models/auth/user_model.py b/app/models/auth/user_model.py index 32cc935..8cb0914 100644 --- a/app/models/auth/user_model.py +++ b/app/models/auth/user_model.py @@ -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"" - diff --git a/app/models/system/alert_state_model.py b/app/models/system/alert_state_model.py index befd17b..5361a65 100644 --- a/app/models/system/alert_state_model.py +++ b/app/models/system/alert_state_model.py @@ -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) diff --git a/app/models/system/audit_log.py b/app/models/system/audit_log.py index ae91885..8f0b8e0 100644 --- a/app/models/system/audit_log.py +++ b/app/models/system/audit_log.py @@ -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" 50, "accounts": [ diff --git a/app/routes/auth/api_key.py b/app/routes/auth/api_key.py index 9bdd8cf..8e994ab 100644 --- a/app/routes/auth/api_key.py +++ b/app/routes/auth/api_key.py @@ -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, diff --git a/app/routes/auth/auth.py b/app/routes/auth/auth.py index 72f172f..d1a31b4 100644 --- a/app/routes/auth/auth.py +++ b/app/routes/auth/auth.py @@ -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, diff --git a/app/routes/auth/identity_provider.py b/app/routes/auth/identity_provider.py index f0192f9..d3a4ce1 100644 --- a/app/routes/auth/identity_provider.py +++ b/app/routes/auth/identity_provider.py @@ -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 = ( diff --git a/app/routes/auth/invitation.py b/app/routes/auth/invitation.py index cb29746..0b437f3 100644 --- a/app/routes/auth/invitation.py +++ b/app/routes/auth/invitation.py @@ -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), diff --git a/app/routes/auth/mfa.py b/app/routes/auth/mfa.py index 41047bc..12cf6da 100644 --- a/app/routes/auth/mfa.py +++ b/app/routes/auth/mfa.py @@ -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, diff --git a/app/routes/auth/org_unit.py b/app/routes/auth/org_unit.py index db4a08b..4337803 100644 --- a/app/routes/auth/org_unit.py +++ b/app/routes/auth/org_unit.py @@ -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, diff --git a/app/routes/auth/scim.py b/app/routes/auth/scim.py index 3bf4f6f..51d9969 100644 --- a/app/routes/auth/scim.py +++ b/app/routes/auth/scim.py @@ -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, diff --git a/app/routes/auth/sso.py b/app/routes/auth/sso.py index 388fc42..43e2eaf 100644 --- a/app/routes/auth/sso.py +++ b/app/routes/auth/sso.py @@ -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()}, - ) \ No newline at end of file + ) diff --git a/app/routes/auth/tenant.py b/app/routes/auth/tenant.py index 91eafd2..e92bca7 100644 --- a/app/routes/auth/tenant.py +++ b/app/routes/auth/tenant.py @@ -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, - ) \ No newline at end of file + ) diff --git a/app/routes/internal/identity.py b/app/routes/internal/identity.py index b1268f4..ec33c0e 100644 --- a/app/routes/internal/identity.py +++ b/app/routes/internal/identity.py @@ -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 diff --git a/app/routes/system/document.py b/app/routes/system/document.py index a60c7b3..0269ca8 100644 --- a/app/routes/system/document.py +++ b/app/routes/system/document.py @@ -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", }, ) diff --git a/app/routes/system/lookup.py b/app/routes/system/lookup.py index 3d3c334..7004f5a 100644 --- a/app/routes/system/lookup.py +++ b/app/routes/system/lookup.py @@ -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) diff --git a/app/routes/system/notification.py b/app/routes/system/notification.py index 07b3aad..e7043f9 100644 --- a/app/routes/system/notification.py +++ b/app/routes/system/notification.py @@ -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() diff --git a/app/routes/system/tenant_email.py b/app/routes/system/tenant_email.py index 911b5d0..6572536 100644 --- a/app/routes/system/tenant_email.py +++ b/app/routes/system/tenant_email.py @@ -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 diff --git a/app/routes/system/webhook.py b/app/routes/system/webhook.py index 24f5237..bcfa174 100644 --- a/app/routes/system/webhook.py +++ b/app/routes/system/webhook.py @@ -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 diff --git a/app/schemas/auth/api_key_schema.py b/app/schemas/auth/api_key_schema.py index 198aec5..f912dd0 100644 --- a/app/schemas/auth/api_key_schema.py +++ b/app/schemas/auth/api_key_schema.py @@ -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 diff --git a/app/schemas/auth/auth_schema.py b/app/schemas/auth/auth_schema.py index 71e5974..a49869e 100644 --- a/app/schemas/auth/auth_schema.py +++ b/app/schemas/auth/auth_schema.py @@ -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): diff --git a/app/schemas/auth/identity_provider_schema.py b/app/schemas/auth/identity_provider_schema.py index bf37552..b567a8f 100644 --- a/app/schemas/auth/identity_provider_schema.py +++ b/app/schemas/auth/identity_provider_schema.py @@ -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 diff --git a/app/schemas/auth/invitation_schema.py b/app/schemas/auth/invitation_schema.py index 66b1a1c..ff87c12 100644 --- a/app/schemas/auth/invitation_schema.py +++ b/app/schemas/auth/invitation_schema.py @@ -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 diff --git a/app/schemas/auth/mfa_schema.py b/app/schemas/auth/mfa_schema.py index b93a3be..611f13b 100644 --- a/app/schemas/auth/mfa_schema.py +++ b/app/schemas/auth/mfa_schema.py @@ -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 diff --git a/app/schemas/auth/subscription_history_schema.py b/app/schemas/auth/subscription_history_schema.py index 56ee46c..03c57a3 100644 --- a/app/schemas/auth/subscription_history_schema.py +++ b/app/schemas/auth/subscription_history_schema.py @@ -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 diff --git a/app/schemas/auth/subscription_plan_schema.py b/app/schemas/auth/subscription_plan_schema.py index 31ce7bd..0c25e5d 100644 --- a/app/schemas/auth/subscription_plan_schema.py +++ b/app/schemas/auth/subscription_plan_schema.py @@ -9,9 +9,6 @@ class SubscriptionPlanBase(BaseModel): price: Optional[float] = None duration_days: Optional[int] = Field(None, ge=1) max_users_allowed: Optional[int] = Field(None, ge=0) - # How long after expiry a workspace stays read-only rather than locked out. - # A commercial decision per plan, so it belongs here rather than as a - # constant. 0 is the old behaviour: straight from active to locked out. grace_period_days: int = Field(0, ge=0) is_public: bool = True status: str = "active" diff --git a/app/schemas/auth/tenant_schema.py b/app/schemas/auth/tenant_schema.py index 79dd47f..bfb3eb2 100644 --- a/app/schemas/auth/tenant_schema.py +++ b/app/schemas/auth/tenant_schema.py @@ -14,9 +14,6 @@ class ModuleEnvironmentAssignment(BaseModel): class TenantCreate(TenantBase): plan_id: uuid.UUID - # Who to tell when the subscription is about to lapse. Optional, because a - # workspace can be set up before anyone knows who pays — but a workspace - # without one gets no warning at all, and the notices log that gap. billing_email: Optional[EmailStr] = None start_date: Optional[date] = None end_date: Optional[date] = None @@ -34,9 +31,6 @@ class TenantUpdate(BaseModel): start_date: Optional[date] = None end_date: Optional[date] = None status: Optional[str] = None - # Who to tell when the subscription is about to lapse. Optional, because a - # workspace can be set up before anyone knows who pays — but a workspace - # without one gets no warning at all, and the notices log that gap. billing_email: Optional[EmailStr] = None class TenantResponse(TenantBase): @@ -59,4 +53,4 @@ class TenantPaginatedResponse(BaseModel): total: int page: int page_size: int - total_pages: int \ No newline at end of file + total_pages: int diff --git a/app/schemas/system/webhook_schema.py b/app/schemas/system/webhook_schema.py index bc053a1..3fb20ce 100644 --- a/app/schemas/system/webhook_schema.py +++ b/app/schemas/system/webhook_schema.py @@ -17,16 +17,12 @@ from pydantic import BaseModel, ConfigDict, Field class WebhookEndpointCreate(BaseModel): url: str = Field(min_length=1, max_length=2048) description: Optional[str] = Field(default=None, max_length=255) - # Empty means every event. event_types: List[str] = Field(default_factory=list) class WebhookEndpointUpdate(BaseModel): description: Optional[str] = Field(default=None, max_length=255) event_types: Optional[List[str]] = None - # Turning one back on also clears the failure count and the disabled reason - # — otherwise an endpoint disabled after twenty failures is one failure away - # from being switched off again. is_active: Optional[bool] = None @@ -47,8 +43,6 @@ class WebhookEndpointResponse(BaseModel): class WebhookEndpointCreated(BaseModel): endpoint: WebhookEndpointResponse - # Shown here and on rotation. Encrypted at rest rather than hashed, because - # the customer has to put this exact value into their receiver. secret: str diff --git a/app/services/auth/access_service.py b/app/services/auth/access_service.py index 456a268..eb6ba2f 100644 --- a/app/services/auth/access_service.py +++ b/app/services/auth/access_service.py @@ -11,12 +11,10 @@ from datetime import datetime logger = logging.getLogger(__name__) class AccessService: - CACHE_PREFIX = "saas:access:v2:all:" @staticmethod def get_all_accesses(db: Session, category: str = None) -> List[any]: - cache_key = f"{AccessService.CACHE_PREFIX}{category if category else 'full'}" cached_data = sync_redis_client.client.get(cache_key) if sync_redis_client.client else None @@ -101,7 +99,6 @@ class AccessService: if keys: client.delete(*keys) except Exception as e: - # A cache that will not clear is a stale cache, not a failed request. logger.warning(f"Access cache invalidation error: {e}") @staticmethod @@ -112,6 +109,4 @@ class AccessService: all_cats = {cat[0] for cat in categories} | { cat[0] for cat in module_categories } - # Sorted, because this fills a dropdown. Returning a set's iteration - # order reordered it between requests for no reason anyone could see. - return sorted(c for c in all_cats if c) \ No newline at end of file + return sorted(c for c in all_cats if c) diff --git a/app/services/auth/api_key_service.py b/app/services/auth/api_key_service.py index 0788a3d..28fb64b 100644 --- a/app/services/auth/api_key_service.py +++ b/app/services/auth/api_key_service.py @@ -55,21 +55,13 @@ from app.models.auth.user_model import User logger = logging.getLogger(__name__) PREFIX = "sk_" -PREFIX_BYTES = 6 # 12 hex characters, comfortably unique +PREFIX_BYTES = 6 SECRET_BYTES = 32 MAX_KEYS_PER_WORKSPACE = 50 -# `last_used_at` is written at most this often. The question it answers is "is -# this key still in use", which a minute's resolution answers perfectly well — -# and writing on every call would turn every read request into a write. TOUCH_INTERVAL_SECONDS = 60 -# Which key, if any, the current request is acting under. Read by the audit log -# so an entry can say "via API key 'ci-deploy'" without every route having to -# pass it along — and set in middleware rather than in a dependency, because a -# sync dependency runs in a worker thread with a *copy* of the context, so -# anything it sets is discarded before the endpoint runs. _acting_key: ContextVar[str | None] = ContextVar("saas_acting_api_key", default=None) @@ -124,9 +116,6 @@ def create( .count() ) if live >= MAX_KEYS_PER_WORKSPACE: - # Not a licensing limit — a blast-radius one. A workspace with hundreds - # of live keys cannot say what any of them are for, and revoking the - # wrong one becomes the expensive mistake. raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=( @@ -196,9 +185,6 @@ def resolve(db: Session, raw: str) -> Optional[ApiKey]: key = db.query(ApiKey).filter(ApiKey.prefix == prefix).first() if key is None: - # Compared anyway, against a value that cannot match, so that an unknown - # prefix costs the same as a known one with a wrong secret. Otherwise the - # difference in timing is itself an oracle for which prefixes exist. hmac.compare_digest(hash_secret(secret), "0" * 64) return None @@ -254,7 +240,6 @@ def effective_scopes(db: Session, key: ApiKey, owner: User) -> set[str]: held = set(SubscriptionEntitlementService.get_effective_access_codes(db, owner)) requested = set(key.scope_list()) - # Empty means "whatever the owner can do" — see ApiKey.scope_list. return held if not requested else held & requested diff --git a/app/services/auth/auth_service.py b/app/services/auth/auth_service.py index a833e51..518584f 100644 --- a/app/services/auth/auth_service.py +++ b/app/services/auth/auth_service.py @@ -22,14 +22,10 @@ from app.services.auth.subscription_entitlement_service import ( logger = logging.getLogger(__name__) class AuthService: - @staticmethod def create_user( db: Session, user_data: UserSignup, tenant_id: uuid.UUID = None ) -> User: - # Email uniqueness is global, so the check must be too — a scoped one - # would let the same address be registered once per workspace and then - # fail on the database constraint with a confusing error. with unscoped(): address = normalise_email(user_data.email) clash = db.query(User).filter(User.email == address).first() @@ -45,11 +41,8 @@ class AuthService: first_name=user_data.first_name, last_name=user_data.last_name, phone_number=user_data.phone_number, - # The server sets the state, not the request. `status` used to be read - # off the signup payload. status="active", tenant_id=tenant_id, - # Never through this path. Superadmin is granted by the seed script. is_superadmin=False, ) @@ -66,29 +59,13 @@ class AuthService: user_agent: str | None = None, ip_address: str | None = None, ): - # Unscoped by necessity: an email address does not say which workspace - # it belongs to, and that is the whole point of signing in. with unscoped(): - # Matched on lower(email) rather than on the column: normalising - # the input only helps if what is stored is normalised too, and a - # row written by anything that predates this would otherwise be an - # account nobody can sign in to. The unique index is on the same - # expression, so this uses it. user = db.query(User).filter( func.lower(User.email) == normalise_email(signin_data.email) ).first() - # One message for every reason a sign-in fails. Saying which one — no - # such account, wrong password, locked, needs a code — tells an attacker - # exactly what to change, and confirms which addresses are real. refused = HTTPException(status_code=401, detail="Invalid credentials") - # Everything below runs unscoped for the same reason the lookup did: - # sign-in has no workspace context, because until the account is found - # nobody knows which workspace it belongs to. Row-level security would - # otherwise hide the very row being updated, and the lockout counter - # would fail with "expected to update 1 row, 0 were matched" — the - # attempt silently uncounted. with unscoped(): return AuthService._authenticate( db, user, signin_data, refused, @@ -108,8 +85,6 @@ class AuthService: if not user: raise refused - # Checked before the password. A locked account should cost an attacker - # a refusal without telling them whether their guess was right. if lockout_service.is_locked(user): raise refused @@ -118,10 +93,6 @@ class AuthService: db.commit() raise refused - # The password was right, so a wrong second factor is the account - # holder mistyping far more often than it is an attack — but it still - # counts, because a stolen password plus guessed codes is the attack - # this factor exists to stop. if mfa_service.is_required(db, user): if not signin_data.mfa_code: lockout_service.record_success(db, user) @@ -165,8 +136,6 @@ class AuthService: The caller has already decided the person is who they say they are. This does not re-check that, and deliberately does not know how it was done. """ - # Recorded before the token is minted, so the token's jti and the - # session row name each other from the outset. session = session_service.start( db, user, user_agent=user_agent, ip_address=ip_address ) @@ -211,12 +180,6 @@ class AuthService: }, } - # Committed last, on purpose. A commit expires every loaded instance, - # and the next attribute read re-queries — with no workspace context, - # because sign-in and refresh both happen before an access token exists - # to establish one. Row-level security correctly shows that query - # nothing, so an ordinary user.tenant.tenant_name would raise. Read - # everything first; persist once nothing else needs the objects. db.commit() return response @@ -236,13 +199,9 @@ class AuthService: status_code=status.HTTP_403_FORBIDDEN, detail="User account is inactive" ) - # The durable half of the check. The blacklist consulted inside - # verify_refresh_token is a cache and fails open when Redis is - # unreachable; this does not. try: session = session_service.rotate(db, payload.get("jti")) except session_service.SessionRevoked as ended: - # A detected reuse revokes the session — that has to be kept. db.commit() raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=str(ended) @@ -259,9 +218,6 @@ class AuthService: jti=session.current_jti, ) - # Rotate: the presented token is spent. Without this a captured refresh - # token stayed valid for its full ten days alongside every successor - # minted from it, and the reuse was undetectable. security.revoke_token(refresh_token, settings.REFRESH_TOKEN_SECRET) effective_accesses = sorted( @@ -300,12 +256,6 @@ class AuthService: }, } - # Committed last, on purpose. A commit expires every loaded instance, - # and the next attribute read re-queries — with no workspace context, - # because sign-in and refresh both happen before an access token exists - # to establish one. Row-level security correctly shows that query - # nothing, so an ordinary user.tenant.tenant_name would raise. Read - # everything first; persist once nothing else needs the objects. db.commit() return response @@ -313,7 +263,6 @@ class AuthService: def update_user( db: Session, user_id: uuid.UUID, update_data: UserUpdate, current_user: User ): - if current_user.id != user_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -328,10 +277,6 @@ class AuthService: update_dict = update_data.model_dump(exclude_unset=True) - # Allowlist, not a blanket setattr over whatever the schema happened to - # carry. The schema is the first line of defence; this is the second, so - # that adding a field to UserUpdate cannot silently make it - # self-assignable. SELF_EDITABLE = {"first_name", "last_name", "phone_number", "preferred_language"} rejected = set(update_dict) - SELF_EDITABLE if rejected: @@ -440,8 +385,6 @@ class AuthService: .first() ) if session is None: - # 404 rather than 403: another user's session id must not be - # distinguishable from one that does not exist. raise HTTPException(status_code=404, detail="Session not found") session_service.revoke(db, session) @@ -515,7 +458,7 @@ class AuthService: redis_key = f"otp:{email}" try: if sync_redis_client.client: - sync_redis_client.client.setex(redis_key, 600, otp_code) # 600s = 10 minutes + sync_redis_client.client.setex(redis_key, 600, otp_code) logger.info(f"OTP generated for email: {email}") else: logger.error("Redis client unavailable for OTP storage") @@ -594,16 +537,6 @@ class AuthService: detail="Invalid or expired OTP" ) - # The OTP is deliberately NOT consumed here, and no "verified" marker - # is written. - # - # This step used to delete the OTP and set otp_verified:{email} for - # five minutes; reset_password_with_otp then skipped OTP validation - # entirely whenever that marker was present. Nothing bound the marker - # to the client that had actually proved the code, so for those five - # minutes any caller who knew the address could set the password with - # a wrong OTP. The reset step now always validates, and consumes the - # OTP itself. sync_redis_client.client.delete(f"otp_attempts:{email}") logger.info(f"OTP verified successfully for email: {email}") @@ -632,7 +565,6 @@ class AuthService: AuthService._check_otp_attempts(email) - # Always validate. There is no pre-verified shortcut. stored_otp = sync_redis_client.client.get(redis_key) if not stored_otp: diff --git a/app/services/auth/event_service.py b/app/services/auth/event_service.py index 61e5366..20eaee0 100644 --- a/app/services/auth/event_service.py +++ b/app/services/auth/event_service.py @@ -18,9 +18,6 @@ from app.core.redis import sync_redis_client logger = logging.getLogger(__name__) class EventService: - - # Events whose whole purpose is to reach another system. Delivering one to - # nobody is not the same kind of non-event as an informational broadcast. MUST_REACH_SOMEONE = { "TENANT_PROVISION_REQUESTED", "USER_PROVISION_REQUESTED", @@ -92,10 +89,6 @@ class EventService: targets.append(env) if not targets: - # Not an error: a workspace on a plan that carries no modules has - # genuinely nobody to tell. But a provisioning request that reaches - # nobody means the workspace exists here and nowhere else, and that - # is worth more than a line saying an event was dropped. severity = ( logger.error if event_type in EventService.MUST_REACH_SOMEONE @@ -135,30 +128,12 @@ class EventService: except Exception as e: logger.error(f"Failed to push event to Redis queue: {e}") - # How many modules this will actually reach, so a caller can say so. return len(targets) - # ------------------------------------------------------------------ - # Delivery - # - # This existed twice, once per entry point, and the copies had drifted: - # one counted successes, the other did not; one computed its backoff from - # the moment of failure, the other from a timestamp captured before the - # batch began. Same defect class as the subscription off-by-one — one - # behaviour, two implementations, and no way to fix a bug in both at once. - # ------------------------------------------------------------------ MAX_ATTEMPTS = 10 MAX_BACKOFF_SECONDS = 86400 - # Replay protection on this channel is deduplication, not a freshness - # window. An outbox retries — legitimately, for up to a day — so a receiver - # rejecting anything older than N seconds would drop exactly the events the - # outbox exists to deliver. What makes a replay harmless is that `event_id` - # is stable across retries and covered by the signature: applying an event - # twice must be a no-op on the receiving side, which is what the contract - # requires. Cross-target replay is separately impossible, because every - # environment signs with its own secret. SIGNATURE_VERSION = "1" @staticmethod @@ -188,8 +163,6 @@ class EventService: ).first() if not env: - # Terminal, not transient. There is no configuration to retry - # against, so retrying is just a slower way to never deliver. log.status = EventStatus.FAILED log.error_log = "Target environment config missing" return False @@ -209,20 +182,9 @@ class EventService: headers = { "Content-Type": "application/json", "X-SaaS-Signature": signature, - # Declares the canonical form being signed, so a future change to - # it is something a receiver can detect rather than a silent - # break. v1 is HMAC-SHA256 over the raw request body. "X-SaaS-Signature-Version": EventService.SIGNATURE_VERSION, "X-SaaS-Event-Source": "saas-core", - # A hint, so a receiver can dedupe before parsing. Headers are - # NOT covered by the signature — the authoritative value is - # `event_id` inside the body, and MODULE_CONTRACT.md says to - # dedupe on that one. A receiver trusting this header instead has - # given an attacker a free choice of idempotency key. "X-SaaS-Event-Id": str(log.event_id), - # Retries reuse the event id on purpose. Without this a receiver - # cannot tell a redelivery from a genuine duplicate, and the two - # want different log lines. "X-SaaS-Delivery-Attempt": str((log.retry_count or 0) + 1), } diff --git a/app/services/auth/identity_provider_service.py b/app/services/auth/identity_provider_service.py index 515d4d2..3335667 100644 --- a/app/services/auth/identity_provider_service.py +++ b/app/services/auth/identity_provider_service.py @@ -67,12 +67,8 @@ from app.models.auth.user_model import User logger = logging.getLogger(__name__) -# Long enough for a slow provider and a person reading a consent screen, short -# enough that an abandoned login is not a credential lying around. LOGIN_STATE_TTL_SECONDS = 600 -# A provider that does not answer promptly is a provider that is down. Waiting -# longer ties up a worker and the user is already staring at a blank page. HTTP_TIMEOUT_SECONDS = 10 @@ -105,7 +101,7 @@ def _fetch_json(url: str) -> dict[str, Any]: pinned, headers={"Host": parts.netloc}, timeout=HTTP_TIMEOUT_SECONDS, - follow_redirects=False, # a redirect is a second URL nobody checked + follow_redirects=False, ) response.raise_for_status() return response.json() @@ -118,9 +114,6 @@ def _fetch_json(url: str) -> dict[str, Any]: ) -# ------------------------------------------------------------------ discovery - - def discover(db: Session, provider: IdentityProvider) -> IdentityProvider: """Read the provider's own description of itself. @@ -137,9 +130,6 @@ def discover(db: Session, provider: IdentityProvider) -> IdentityProvider: url = provider.issuer.rstrip("/") + "/.well-known/openid-configuration" document = _fetch_json(url) - # The issuer inside the document must match the one configured. A document - # claiming to be somebody else is either a misconfiguration or a redirect - # somewhere unintended, and both should stop here. declared = (document.get("issuer") or "").rstrip("/") if declared and declared != provider.issuer.rstrip("/"): raise HTTPException( @@ -164,9 +154,6 @@ def discover(db: Session, provider: IdentityProvider) -> IdentityProvider: return provider -# ------------------------------------------------------------------ authorize - - def _pkce_pair() -> tuple[str, str]: verifier = base64.urlsafe_b64encode(secrets.token_bytes(64)).rstrip(b"=").decode() challenge = ( @@ -227,9 +214,6 @@ def begin_login( return f"{provider.authorization_endpoint}?{query}" -# ------------------------------------------------------------------- callback - - def _consume_state(db: Session, state: str) -> SsoLoginState: """Take the state, and take it away. @@ -286,8 +270,6 @@ def _exchange_code( ) if response.status_code >= 400: - # The provider's own message is not shown: it can quote the code back, - # and this response reaches a browser. logger.warning( "Token exchange refused by %s: %s", provider.slug, response.text[:500] ) @@ -325,8 +307,6 @@ def _verify_id_token(provider: IdentityProvider, token: str, nonce: str) -> dict detail=f"The identity provider's response could not be verified: {e}", ) - # Not covered by `jwt.decode`, and the check that ties this token to this - # login rather than to any other. if claims.get("nonce") != nonce: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, @@ -365,8 +345,6 @@ def _resolve_user( if identity: user = db.query(User).filter(User.id == identity.user_id).first() if user is None: - # The account was deleted while the link survived. Refusing is right: - # re-provisioning here would resurrect somebody who was removed. raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="This identity is no longer linked to an account", @@ -389,8 +367,6 @@ def _resolve_user( ) if existing and not provider.link_existing_by_email: - # Deliberate. A provider that does not verify addresses would let anyone - # who can assert an address take over the account holding it. raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=( @@ -444,9 +420,6 @@ def _provision( user = User( email=email, - # No password. This account exists only through the provider, and a - # placeholder that hashes to something would be a password somebody - # could eventually guess. password="!", first_name=claims.get("given_name") or claims.get("name") or email.split("@")[0], last_name=claims.get("family_name"), diff --git a/app/services/auth/invitation_service.py b/app/services/auth/invitation_service.py index fb8752e..432660c 100644 --- a/app/services/auth/invitation_service.py +++ b/app/services/auth/invitation_service.py @@ -60,8 +60,6 @@ from app.services.system import notification_service, webhook_events, webhook_se logger = logging.getLogger(__name__) -# Long enough to survive a weekend and a holiday, short enough that a token in -# an abandoned mailbox stops being a way in. INVITATION_TTL_DAYS = 7 @@ -101,20 +99,11 @@ def create( detail="An email address is required") if _email_taken(db, address): - # Deliberately not "in this workspace" or "in another one": the - # constraint is global, so some existence signal is unavoidable, but - # naming where would turn this into a way to ask which customers a - # competitor has. raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered") - # Reserve a seat now so an administrator finds out immediately rather than - # after the invitee has filled in a form. SeatService.assert_seat_available(db, tenant_id) - # A second invitation supersedes the first. Two live tokens in two copies of - # the same email is a way for a forwarded message to stay usable after the - # sender thought they had replaced it. superseded = ( db.query(UserInvitation) .filter( @@ -146,9 +135,6 @@ def create( webhook_service.emit( db, tenant_id=tenant_id, event_type=webhook_events.USER_INVITED, - # Never the token. A webhook payload is delivered to a URL a customer - # typed, and an invitation token in it would be a working credential - # sent to whatever that URL turns out to be. data={"invitation_id": str(invitation.id), "email": invitation.email}, ) return invitation, raw @@ -179,10 +165,6 @@ def send(db: Session, invitation: UserInvitation, raw_token: str) -> bool: "If you were not expecting this, ignore it and nothing happens." ) try: - # Through the workspace's own account when it has one. This is the - # message that most needs to: it is the one a stranger receives, and one - # arriving from an unfamiliar sender with no SPF alignment to the domain - # it talks about is the definition of what a spam filter is looking for. return EmailService.send_as_tenant( db, invitation.tenant_id, invitation.email, "Invitation to join " + workspace, body, @@ -242,7 +224,6 @@ def accept( detail="Password too weak") if _email_taken(db, invitation.email): - # Somebody was added by another route while this sat in a mailbox. invitation.revoked_at = _now() db.flush() raise HTTPException( @@ -250,8 +231,6 @@ def accept( detail="An account already exists for this address. Sign in instead.", ) - # Checked again, not only at send time: ten invitations against nine - # seats is normal, and this is where the tenth has to be refused. SeatService.assert_seat_available(db, invitation.tenant_id) user = User( @@ -282,8 +261,6 @@ def accept( ) if invitation.invited_by_id: - # To the person who sent it, not to every administrator. They are the one - # waiting to know whether it landed. notification_service.notify( db, user_id=invitation.invited_by_id, diff --git a/app/services/auth/lockout_service.py b/app/services/auth/lockout_service.py index aee7a27..3ec1924 100644 --- a/app/services/auth/lockout_service.py +++ b/app/services/auth/lockout_service.py @@ -34,16 +34,10 @@ from app.models.auth.user_model import User logger = logging.getLogger(__name__) -# Generous enough that somebody with three keyboards and a caps lock is fine, -# tight enough that guessing is not a strategy. MAX_ATTEMPTS = 10 -# Long enough to make guessing pointless, short enough that a locked-out person -# is not filing a support ticket. LOCKOUT_MINUTES = 15 -# Failures older than this are forgotten. Three typos a month apart are not an -# attack, and treating them as one is how an account ends up locked for nothing. DECAY_MINUTES = 60 @@ -62,17 +56,6 @@ def record_failure(db: Session, user: User) -> None: and user.last_failed_login_at < _now() - timedelta(minutes=DECAY_MINUTES) ) - # A lockout that has run its course starts the count again. - # - # Without this the counter stays at the threshold after the lock expires, so - # the *next single mistake* re-locks the account for another full period — - # and the one after that, indefinitely. Only a successful sign-in clears it, - # which is exactly what somebody locked out cannot do. A person who mistypes - # once at minute sixteen is locked out again for a quarter of an hour, and - # nothing tells them why. - # - # Serving the lockout **is** the consequence. Being still at the threshold - # afterwards means serving it changed nothing. served = user.locked_until is not None and user.locked_until <= _now() count = 1 if (stale or served) else (user.failed_login_attempts or 0) + 1 @@ -80,22 +63,14 @@ def record_failure(db: Session, user: User) -> None: user.failed_login_attempts = count user.last_failed_login_at = _now() if served: - # Cleared with the count, so a later read cannot mistake a spent lock - # for a live one. user.locked_until = None if count >= MAX_ATTEMPTS: user.locked_until = _now() + timedelta(minutes=LOCKOUT_MINUTES) - # Logged at warning: this is the signal that somebody is being attacked, - # and it is the only place it appears until the alerting picks it up. logger.warning( "Account %s locked after %s consecutive failed sign-ins", user.email, count, ) - # To the account holder. The refusal itself deliberately tells the person - # at the keyboard nothing — that is the point of it — so this is the only - # place the real owner finds out somebody has been trying, and they see - # it the next time they get in. from app.services.system import notification_service notification_service.notify( diff --git a/app/services/auth/mfa_service.py b/app/services/auth/mfa_service.py index a619ed9..0ad4239 100644 --- a/app/services/auth/mfa_service.py +++ b/app/services/auth/mfa_service.py @@ -43,8 +43,6 @@ from app.config.security import security from app.models.auth.user_model import User from app.models.system.mfa_model import MfaRecoveryCode, UserMfa -# One step either side of now. Phones drift and people type slowly; two steps -# would nearly double the window an intercepted code is worth. VALID_WINDOW = 1 RECOVERY_CODE_COUNT = 10 @@ -83,9 +81,6 @@ def begin_enrolment(db: Session, user: User, issuer: str = "SaaS") -> dict: detail="This account already has a second factor. Remove it first.", ) - # Restarting enrolment replaces the unproved secret rather than adding a - # second: somebody who scanned a code, lost the phone and started again - # should not end up with two. if existing: db.delete(existing) db.flush() @@ -98,8 +93,6 @@ def begin_enrolment(db: Session, user: User, issuer: str = "SaaS") -> dict: return { "secret": secret, - # The URI the QR code encodes. Returned once, at enrolment, and never - # readable afterwards — the secret is not a thing the API hands back. "otpauth_uri": pyotp.TOTP(secret).provisioning_uri( name=user.email, issuer_name=issuer ), @@ -142,8 +135,6 @@ def _issue_recovery_codes(db: Session, user: User) -> list[str]: codes = [] for _ in range(RECOVERY_CODE_COUNT): - # Grouped for transcription: these get written down, and a wall of - # characters is where a digit gets lost. raw = f"{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}" codes.append(raw) db.add( @@ -169,13 +160,11 @@ def _accept(db: Session, factor: UserMfa, code: str) -> bool: return False totp = pyotp.TOTP(secret) - # Which step this code belongs to, so a used one can be recognised. now = int(_now().timestamp()) for offset in range(-VALID_WINDOW, VALID_WINDOW + 1): counter = (now // totp.interval) + offset if secrets.compare_digest(totp.at(counter * totp.interval), code.strip()): if factor.last_counter is not None and counter <= factor.last_counter: - # Already spent. A code read over a shoulder is worth nothing. return False factor.last_counter = counter db.flush() @@ -187,8 +176,6 @@ def verify(db: Session, user: User, code: str) -> bool: """A code or a recovery code. Either satisfies the factor.""" factor = _factor(db, user) if factor is None or not factor.is_active: - # Nothing to satisfy. Answering False would lock out an account that - # never had a second factor. return True if _accept(db, factor, code): @@ -202,8 +189,6 @@ def _consume_recovery_code(db: Session, user: User, code: str) -> bool: if not candidate: return False - # Every unused code is checked, because they are hashed and cannot be looked - # up. Ten bcrypt comparisons is slow by design. for record in ( db.query(MfaRecoveryCode) .filter(MfaRecoveryCode.user_id == user.id, MfaRecoveryCode.used_at.is_(None)) diff --git a/app/services/auth/module_environment_service.py b/app/services/auth/module_environment_service.py index 7891f86..fea8a74 100644 --- a/app/services/auth/module_environment_service.py +++ b/app/services/auth/module_environment_service.py @@ -39,17 +39,12 @@ class ModuleEnvironmentService: sso_entry_path=env_data.sso_entry_path, permission_sync_endpoint=env_data.permission_sync_endpoint, sso_exchange_endpoint=env_data.sso_exchange_endpoint, - # Was accepted by the schema and never read here, so a module - # with a non-standard provisioning path was configured through - # the console, answered with a 200, and then had every - # provisioning event posted to the default path instead. provisioning_endpoint=env_data.provisioning_endpoint, trust_type=env_data.trust_type, trust_credentials={}, is_default=env_data.is_default, is_active=env_data.is_active ) - # Through the accessor, so the secret is encrypted before it is stored. environment.credentials = env_data.trust_credentials db.add(environment) db.commit() @@ -78,8 +73,6 @@ class ModuleEnvironmentService: update_data = env_data.model_dump(exclude_unset=True) - # Credentials go through the encrypting accessor; everything else is a - # plain column assignment. new_credentials = update_data.pop("trust_credentials", None) for key, value in update_data.items(): setattr(environment, key, value) @@ -120,11 +113,6 @@ class ModuleEnvironmentService: if environment.is_default: raise HTTPException(status_code=400, detail="Cannot delete default environment. Set another environment as default first.") - # `assigned_environment_slug` is a plain string, not a foreign key, so - # deleting an environment a workspace is pinned to broke nothing - # visibly: the handoff looks the slug up, finds nothing, and falls back - # to the module's default — which is production. The same silent - # promotion that a misspelled slug used to cause, through the other door. from app.models.auth.tenant_module_model import TenantModule pinned = db.query(TenantModule).filter( @@ -142,4 +130,4 @@ class ModuleEnvironmentService: ) db.delete(environment) - db.commit() \ No newline at end of file + db.commit() diff --git a/app/services/auth/module_identity.py b/app/services/auth/module_identity.py index 69da962..ed0f846 100644 --- a/app/services/auth/module_identity.py +++ b/app/services/auth/module_identity.py @@ -55,9 +55,6 @@ def _public_numbers() -> Optional[tuple[int, int]]: settings.SAAS_PRIVATE_KEY.encode(), password=None ) except Exception as e: - # A malformed key is a deployment error, not a request error. Reported - # here and again by the health check, rather than surfacing as a 500 on - # whichever endpoint happens to be called first. logger.error(f"SAAS_PRIVATE_KEY could not be parsed: {e}") return None @@ -83,9 +80,6 @@ def jwks() -> dict: "kty": "RSA", "use": "sig", "alg": "RS256", - # Matches the `kid` header on every token signed with this key, - # which is what makes rotation possible: publish both, sign with - # the new one, retire the old once nothing carries it. "kid": settings.SAAS_KEY_ID, "n": _b64(modulus), "e": _b64(exponent), diff --git a/app/services/auth/module_permission_service.py b/app/services/auth/module_permission_service.py index 5fb4529..c13e427 100644 --- a/app/services/auth/module_permission_service.py +++ b/app/services/auth/module_permission_service.py @@ -15,7 +15,6 @@ import uuid from app.core.redis import sync_redis_client class ModulePermissionService: - @staticmethod def sync_permissions(db: Session, module_id: str): """ @@ -48,7 +47,6 @@ class ModulePermissionService: raise HTTPException(status_code=400, detail="No active environment to sync from") try: - payload_body = "{}" secret = env.credentials.get("hmac_secret") @@ -123,16 +121,10 @@ class ModulePermissionService: ModulePermissionService._link_parents(db, module, permissions, permission_map) - # Permissions the module used to declare and no longer does are reported, - # not deleted. Deleting would silently revoke them from every role that - # holds them — a permission change nobody asked for and nobody sees. - # Naming them lets an administrator decide. stale = sorted(known_before - set(permission_map)) db.commit() - # Every cached shape, not just the unfiltered one — a sync changes what - # the permission picker should show under each category too. AccessService.invalidate_cache() return { @@ -158,7 +150,6 @@ class ModulePermissionService: parent_code = perm.get("parent_code") if not code or not parent_code or code not in permission_map: continue - # A permission cannot be its own parent. if parent_code == code: continue declared_parent[code] = parent_code @@ -175,8 +166,6 @@ class ModulePermissionService: for code, parent_code in declared_parent.items(): if would_loop(code, parent_code): - # Drop this link rather than the whole sync: the permission is - # still real and still assignable, it just sits at the root. declared_parent[code] = None continue @@ -203,4 +192,4 @@ class ModulePermissionService: return db.query(ModuleAccess).filter( ModuleAccess.module_id == module.id - ).all() \ No newline at end of file + ).all() diff --git a/app/services/auth/org_unit_service.py b/app/services/auth/org_unit_service.py index 30026af..fc3ba47 100644 --- a/app/services/auth/org_unit_service.py +++ b/app/services/auth/org_unit_service.py @@ -75,8 +75,6 @@ def create( parent = _own(db, parent_id, tenant_id) if parent_id else None if parent is not None and parent.depth + 1 >= MAX_DEPTH: - # Not a technical limit — a bound on nonsense. A ten-deep hierarchy is - # almost always a data-entry loop rather than an organisation. raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Units cannot nest more than {MAX_DEPTH} deep", @@ -90,8 +88,6 @@ def create( path="/", ) db.add(unit) - # Flushed first because the path contains the unit's own id, which the - # database generates. db.flush() unit.path = _path_for(parent, unit.id) db.flush() @@ -126,9 +122,6 @@ def move(db: Session, unit: OrgUnit, new_parent_id: Optional[uuid.UUID]) -> OrgU raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="A unit cannot be its own parent") if new_parent.path.startswith(unit.path): - # Moving a unit under its own descendant makes a cycle, and the - # symptom of a cycle is a request that never returns rather than an - # error anybody can read. raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="A unit cannot be moved beneath itself", @@ -185,9 +178,6 @@ def delete(db: Session, unit: OrgUnit) -> None: detail=f"{members} member(s) still belong to this unit", ) - # Marked, not removed: the row is empty of members by the check above, but - # the *references to it* are not — audit entries name it, a seat allocation - # points at it, and a report grouped by it last quarter still resolves. from datetime import datetime, timezone unit.deleted_at = datetime.now(timezone.utc) @@ -208,9 +198,6 @@ def _own(db: Session, unit_id: uuid.UUID, tenant_id: uuid.UUID) -> OrgUnit: return unit -# ------------------------------------------------------------- membership - - def assign(db: Session, *, user: User, unit: OrgUnit, primary: bool = False, lead: bool = False) -> UserOrgUnit: """Put somebody in a unit. @@ -226,9 +213,6 @@ def assign(db: Session, *, user: User, unit: OrgUnit, ) if existing is None: - # Only for somebody joining. Changing an existing member's primary flag - # or lead flag adds nobody, and refusing it because the unit is full - # would make a full branch unmanageable. from app.services.auth import seat_allocation_service seat_allocation_service.assert_unit_has_room(db, unit.tenant_id, unit) @@ -238,9 +222,6 @@ def assign(db: Session, *, user: User, unit: OrgUnit, membership.is_lead = lead if primary: - # Cleared first. The partial unique index would otherwise refuse the - # write, and "somebody already has a primary unit" is not an error worth - # showing anybody — it is just the previous answer. db.query(UserOrgUnit).filter( UserOrgUnit.user_id == user.id, UserOrgUnit.is_primary.is_(True) ).update({UserOrgUnit.is_primary: False}, synchronize_session=False) @@ -270,9 +251,6 @@ def units_for(db: Session, user: User) -> list[UserOrgUnit]: ) -# --------------------------------------------------------------- scoping - - def grant_admin_scope(db: Session, *, user: User, unit: OrgUnit) -> UserAdminScope: existing = ( db.query(UserAdminScope) @@ -355,9 +333,6 @@ def visible_user_ids(db: Session, actor: User) -> Optional[set[uuid.UUID]]: .filter(UserOrgUnit.org_unit_id.in_(unit_ids)) .all() } - # Always themselves. An administrator who cannot see their own account - # cannot change their own password through the same screens, which reads as - # a bug however correct the scoping is. ids.add(actor.id) return ids diff --git a/app/services/auth/role_service.py b/app/services/auth/role_service.py index d352adf..5f323ef 100644 --- a/app/services/auth/role_service.py +++ b/app/services/auth/role_service.py @@ -18,7 +18,6 @@ from app.services.auth.event_service import EventService logger = logging.getLogger(__name__) class RoleService: - @staticmethod def create_role(db: Session, role_data: RoleCreate, emit_events: bool = True) -> Role: existing = ( @@ -186,8 +185,6 @@ class RoleService: role = query.first() if not role: - # Deliberately 404 rather than 403: a cross-workspace id must not be - # distinguishable from one that does not exist. raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Role not found" ) @@ -323,11 +320,6 @@ class RoleService: detail="Default roles can only be deleted by superadmins.", ) - # Refuse while anyone still holds it. Deleting used to succeed, and the - # relationship's default cascade then set `users.role_id` to NULL for - # every holder — silently. Those accounts kept working and quietly lost - # every permission they had, which reads as a bug in the product rather - # than as the consequence of the click that caused it. holders = db.query(User).filter(User.role_id == role.id).count() if holders: raise HTTPException( @@ -398,7 +390,6 @@ class RoleService: sort_by: Optional[str] = None, sort_order: Optional[str] = None, ) -> RolePaginatedResponse: - query = db.query(Role) if tenant_id is not None: @@ -441,4 +432,4 @@ class RoleService: page=page, page_size=page_size, total_pages=total_pages, - ) \ No newline at end of file + ) diff --git a/app/services/auth/scim_service.py b/app/services/auth/scim_service.py index bfcc607..18012bb 100644 --- a/app/services/auth/scim_service.py +++ b/app/services/auth/scim_service.py @@ -88,9 +88,6 @@ class ScimError(HTTPException): super().__init__(status_code=status_code, detail=body) -# --------------------------------------------------------------- rendering - - def to_scim_user(user: User, base_url: str) -> dict[str, Any]: return { "schemas": [USER_SCHEMA], @@ -103,8 +100,6 @@ def to_scim_user(user: User, base_url: str) -> dict[str, Any]: }, "displayName": " ".join(filter(None, [user.first_name, user.last_name])) or user.email, - # Always exactly one, marked primary. Directories that see a list with no - # primary pick arbitrarily, and some pick nothing. "emails": [{"value": user.email, "primary": True, "type": "work"}], "active": user.status == "active", "meta": { @@ -137,23 +132,12 @@ def list_response(resources: list[dict], total: int, start_index: int, return { "schemas": [LIST_SCHEMA], "totalResults": total, - # 1-based, per the specification. Echoing back what the client sent - # rather than recomputing it, because a client paginating on its own - # arithmetic and a server paginating on different arithmetic is how a - # sync silently skips a user per page. "startIndex": start_index, "itemsPerPage": count, "Resources": resources, } -# ------------------------------------------------------------------ filters - -# The specification's filter grammar is large; these directories use one clause -# of it. Supporting exactly that, and refusing the rest explicitly, is honest — -# a half-implemented filter that silently ignores what it does not understand -# returns *everything*, and a client that asked "does this user exist" is told -# yes about somebody else. _FILTER = re.compile( r'^\s*(?PuserName|externalId|displayName|emails\.value|emails\[type eq "work"\]\.value)' r'\s+eq\s+"(?P[^"]*)"\s*$', @@ -181,18 +165,12 @@ def parse_filter(expression: Optional[str]) -> Optional[str]: return match.group("value") -# ------------------------------------------------------------------ queries - - def find_users(db: Session, tenant_id: uuid.UUID, *, term: Optional[str], start_index: int, count: int) -> tuple[list[User], int]: query = db.query(User).filter( User.tenant_id == tenant_id, User.deleted_at.is_(None) ) if term: - # On `lower(email)`, matching the unique index, because a directory that - # sends `Person@Example.com` and gets "no such user" will helpfully - # create a second account for the same human being. query = query.filter(func.lower(User.email) == normalise_email(term)) total = query.count() @@ -222,9 +200,6 @@ def get_user(db: Session, tenant_id: uuid.UUID, user_id: str) -> User: return user -# ------------------------------------------------------------------- writes - - def create_user(db: Session, tenant_id: uuid.UUID, payload: dict) -> User: address = normalise_email(_username_from(payload)) if not address: @@ -237,8 +212,6 @@ def create_user(db: Session, tenant_id: uuid.UUID, payload: dict) -> User: .first() ) if clash is not None: - # `uniqueness` specifically: it is the one conflict every directory - # knows how to report to an administrator instead of retrying. raise ScimError(409, "A user with this userName already exists", "uniqueness") active = payload.get("active", True) @@ -246,16 +219,11 @@ def create_user(db: Session, tenant_id: uuid.UUID, payload: dict) -> User: try: SeatService.assert_seat_available(db, tenant_id) except HTTPException as e: - # Translated, or the directory sees an unparseable body and retries - # for ever rather than telling anybody the workspace is full. raise ScimError(409, str(e.detail), "uniqueness") name = payload.get("name") or {} user = User( email=address, - # No password. The account signs in through the identity provider, and a - # placeholder that is not a bcrypt hash can never match — `verify_password` - # returns False for it rather than raising. password="!", first_name=name.get("givenName") or payload.get("displayName") or address, last_name=name.get("familyName") or "", @@ -343,8 +311,6 @@ def patch_user(db: Session, tenant_id: uuid.UUID, user_id: str, if path: _apply(db, tenant_id, user, path, value) elif isinstance(value, dict): - # No path: the value is a partial resource. Azure AD sends this - # shape; Okta sends the path form. Both have to work. for attribute, item in value.items(): _apply(db, tenant_id, user, attribute, item) @@ -359,9 +325,6 @@ def _apply(db: Session, tenant_id: uuid.UUID, user: User, path: str, attribute = (path or "").split(".")[-1].lower() if attribute == "active": - # Sent as a real boolean by some, as the string "False" by others. Both - # mean the same thing, and reading "False" as truthy would leave a - # deprovisioned account signed in. if isinstance(value, str): value = value.strip().lower() in ("true", "1", "yes") _set_active(db, tenant_id, user, bool(value)) @@ -380,8 +343,6 @@ def _apply(db: Session, tenant_id: uuid.UUID, user: User, path: str, def _set_active(db: Session, tenant_id: uuid.UUID, user: User, active: bool) -> None: if active and user.status != "active": - # Reactivating consumes a seat, and a workspace that has since filled up - # must not go over its limit because a directory decided so. try: SeatService.assert_seat_available(db, tenant_id) except HTTPException as e: @@ -435,9 +396,6 @@ def _username_from(payload: dict) -> str: return candidate -# ------------------------------------------------------------------- groups - - def find_groups(db: Session, tenant_id: uuid.UUID, *, term: Optional[str], start_index: int, count: int) -> tuple[list[Role], int]: query = db.query(Role).filter(Role.tenant_id == tenant_id) @@ -509,9 +467,6 @@ def patch_group_members(db: Session, tenant_id: uuid.UUID, group_id: str, try: member = get_user(db, tenant_id, str(member_id)) except ScimError: - # A member the directory believes in and we do not. Skipped - # rather than failing the whole operation, which would stop - # every other membership change in the same request. logger.info("SCIM group patch referenced unknown user %s", member_id) continue diff --git a/app/services/auth/seat_allocation_service.py b/app/services/auth/seat_allocation_service.py index 0c660a3..639afc0 100644 --- a/app/services/auth/seat_allocation_service.py +++ b/app/services/auth/seat_allocation_service.py @@ -128,9 +128,6 @@ def set_allocation(db: Session, *, tenant_id: uuid.UUID, unit: OrgUnit, raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="A seat limit cannot be negative") - # Refused before it is stored, not after: a branch that is instantly over a - # limit nobody's action caused is a number an administrator has to fix by - # removing somebody. used = unit_usage(db, tenant_id, unit.id) if seat_limit < used: raise HTTPException( diff --git a/app/services/auth/seat_service.py b/app/services/auth/seat_service.py index 17b7835..dcc5e73 100644 --- a/app/services/auth/seat_service.py +++ b/app/services/auth/seat_service.py @@ -33,7 +33,7 @@ logger = logging.getLogger(__name__) @dataclass(frozen=True, slots=True) class SeatUsage: used: int - limit: int | None # None means unlimited + limit: int | None @property def unlimited(self) -> bool: @@ -57,9 +57,6 @@ class SeatService: charging a seat for them would be wrong in the customer's favour to argue about and wrong in ours to defend. """ - # Scoped to the workspace named by the caller rather than to ambient - # context, so the count is correct whoever asks — and still enforced by - # the policy rather than merely filtered by this query. with scoped_to(tenant_id): return ( db.query(User) @@ -70,11 +67,8 @@ class SeatService: @staticmethod def usage(db: Session, tenant_id: uuid.UUID | None) -> SeatUsage: if tenant_id is None: - # A platform superadmin belongs to no workspace and consumes no seat. return SeatUsage(used=0, limit=None) - # `tenants` carries no tenant_id and so no policy, but the plan lookup - # behind it is safer read without ambient context confusing matters. with unscoped(): tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first() limit = ( @@ -96,7 +90,6 @@ class SeatService: if tenant_id is None: return SeatUsage(used=0, limit=None) - # Serialises seat decisions for this workspace, and only this workspace. with unscoped(): db.execute( select(Tenant).where(Tenant.id == tenant_id).with_for_update() diff --git a/app/services/auth/session_service.py b/app/services/auth/session_service.py index c0198ca..6080e15 100644 --- a/app/services/auth/session_service.py +++ b/app/services/auth/session_service.py @@ -123,15 +123,9 @@ def rotate(db: Session, jti) -> UserSession: ) if session is None: - # No session at all: signed out long ago, or minted before sessions were - # recorded. Either way there is nothing to refresh. raise SessionRevoked("unknown", "Session not found") if session.previous_jti == token_jti and session.current_jti != token_jti: - # The spent token, presented after the real client already rotated. - # Whoever holds it is not the client that rotated, so the session ends — - # refusing only this request would leave the thief free to keep trying - # and the legitimate session running beside them. revoke(db, session, reason="reuse_detected") raise SessionRevoked("reuse_detected", "Refresh token reuse detected") diff --git a/app/services/auth/sso_service.py b/app/services/auth/sso_service.py index 2b4cafa..c43687e 100644 --- a/app/services/auth/sso_service.py +++ b/app/services/auth/sso_service.py @@ -4,10 +4,6 @@ from datetime import datetime, timedelta, timezone from typing import Dict, Any, Optional from sqlalchemy.orm import Session from fastapi import HTTPException, status -# NOTE (B-3): the SSOGrant model and its sso_grants table are dead — grants are -# held in Redis and nothing reads or writes the table. Left in place rather than -# dropped, because a destructive migration on a live system is not worth it for a -# table that costs nothing; the platform does not carry it forward. from app.models.auth.module_model import Module from app.models.auth.module_environment_model import ModuleEnvironment from app.models.auth.tenant_module_model import TenantModule @@ -23,7 +19,6 @@ from app.services.auth.subscription_entitlement_service import ( logger = logging.getLogger(__name__) -# Signed-payload contract. See MODULE_CONTRACT.md in the platform repository. SIGNATURE_VERSION = "2" SIGNED_PAYLOAD_TTL_SECONDS = 120 @@ -113,11 +108,6 @@ class SSOService: if not module or module.status != "active": raise HTTPException(status_code=404, detail="Module not found or disabled") - # Entitlement is enforced here, not just consulted for an environment slug. - # generate_grant() raised 403 when the workspace had no active TenantModule; - # this path — the one initiate_sso actually calls — only read the row to pick - # a slug and proceeded regardless. Two entry points to one trust decision, - # and the permissive one was the one in use. environment_slug = "prod" if tenant_id: tm = db.query(TenantModule).filter( @@ -145,17 +135,12 @@ class SSOService: if not user: raise HTTPException(status_code=404, detail="User not found") - # Role module permissions bounded by the workspace plan. The previous - # form read the plan's module codes and only fell back to the role's when - # the plan set was empty, which handed every user every module permission - # the plan carried, whatever their role. permissions = sorted( SubscriptionEntitlementService.get_effective_module_access_codes( db, user, module.id ) ) - # Subscription must be live for a handoff to carry any entitlement at all. if tenant_id and not SubscriptionEntitlementService.is_subscription_live( SubscriptionEntitlementService.get_tenant(db, tenant_id) ): @@ -180,30 +165,13 @@ class SSOService: "first_name": user.first_name, "last_name": user.last_name, "role": user.role.role_name if user.role else None, - # Identify the intended recipient inside the signed material, so a - # payload minted for one module/environment cannot be presented to - # another. "module_id": module.module_id, "environment": env.slug, - # Replay controls. The platform never sees this POST — the browser - # delivers it — so the receiver is the only party that can reject a - # replay. Both fields are inside the signature; MODULE_CONTRACT.md - # requires receivers to enforce them. "nonce": uuid.uuid4().hex, "issued_at": timestamp, "expires_at": timestamp + (SIGNED_PAYLOAD_TTL_SECONDS * 1000), } - # Sign the WHOLE payload, canonically serialised. - # - # The previous canonical string covered user_id, email, tenant_id and - # timestamp — four fields of a payload that also carried `permissions`, - # `role`, `tenant_name` and the full `subscription` object. Everything - # outside those four was unsigned, and the courier is the user's own - # browser: editing the permissions array in dev tools produced a payload - # the receiving module had no cryptographic way to reject. - # - # sort_keys + compact separators so both sides serialise identically. canonical_string = json.dumps( payload_data, sort_keys=True, separators=(",", ":") ) @@ -225,12 +193,8 @@ class SSOService: "target_url": target_url, "payload": payload_data, "headers": { - # Was declared twice — "saas" then module.module_id — so the first - # never shipped. The module id is the value receivers were given. "X-App-Id": module.module_id, "X-Signature": signature, - # v1 signed four fields of the payload; v2 signs the canonical - # JSON of all of it. Receivers must reject anything that is not v2. "X-Signature-Version": SIGNATURE_VERSION, }, "redirect_url": env.frontend_base_url @@ -270,8 +234,6 @@ class SSOService: if not module or str(module.id) != grant_data["module_id"]: raise HTTPException(status_code=401, detail="Grant invalid for this module") - # Taking a module offline should stop admitting people to it, including - # anyone holding a code minted a minute ago. if module.status != "active": raise HTTPException(status_code=403, detail="Module is disabled") @@ -282,11 +244,6 @@ class SSOService: if not user: raise HTTPException(status_code=401, detail="User not found") - # This path is called by the module backend, not through get_current_user, - # so none of the checks the authentication middleware performs on an - # ordinary request happen unless they happen here. Disabling an account - # otherwise left the holder a working route into every module for as long - # as their grant lived. if user.status != "active": raise HTTPException(status_code=401, detail="User account is not active") @@ -298,7 +255,6 @@ class SSOService: detail="Tenant mismatch for SSO grant" ) - # Entitlement was checked when the grant was minted and never again. tenant_module = db.query(TenantModule).filter( TenantModule.tenant_id == user.tenant_id, TenantModule.module_id == module.id, @@ -309,10 +265,6 @@ class SSOService: detail="Tenant does not have access to this module", ) - # The signed-payload path refused a dead subscription; this one did - # not. It produced a token with an empty permission list, which is - # not the same as a refusal — a module that treats "authenticated" as - # sufficient admitted the user anyway. if not SubscriptionEntitlementService.is_subscription_live( SubscriptionEntitlementService.get_tenant(db, user.tenant_id) ): @@ -337,10 +289,6 @@ class SSOService: "roles": [user.role.role_name] if user.role else [] } - # A missing signing key is a deployment problem, and it used to surface - # as an unhandled ValueError — a 500 with a stack trace, on a - # server-to-server call, telling the module nothing it could act on. - # 503 says "not me, and not now", which is what a retrying client needs. from app.services.auth import module_identity if not module_identity.is_configured(): diff --git a/app/services/auth/subscription_entitlement_service.py b/app/services/auth/subscription_entitlement_service.py index fec29ac..a228a4b 100644 --- a/app/services/auth/subscription_entitlement_service.py +++ b/app/services/auth/subscription_entitlement_service.py @@ -14,12 +14,6 @@ from app.models.auth.plan_module_access_model import PlanModuleAccess from app.models.auth.tenant_model import Tenant from app.models.auth.user_model import User -# Sentinel for "this actor is not bounded by a plan at all". -# -# A platform superadmin has no workspace and therefore no subscription; so does a -# workspace with no plan attached. Both must mean "the role decides", not "no -# permissions" — and neither can be expressed by an empty set, which is why this -# is a distinct value rather than `None` overloaded onto the same return type. UNBOUNDED = object() @@ -28,9 +22,6 @@ class SubscriptionEntitlementService: def get_tenant(db: Session, tenant_id: Optional[uuid.UUID]) -> Optional[Tenant]: if not tenant_id: return None - # `tenants` has no tenant_id column and therefore no policy; reading it - # unscoped keeps entitlement resolution working from any context, - # including the pre-authentication paths that have none yet. with unscoped(): return db.query(Tenant).filter(Tenant.id == tenant_id).first() @@ -114,9 +105,6 @@ class SubscriptionEntitlementService: if tenant is None or not tenant.plan_id: return UNBOUNDED if not SubscriptionEntitlementService.is_subscription_live(tenant): - # A lapsed subscription bounds everything to nothing. The workspace - # keeps its roles; they simply grant no plan-gated permission until - # the subscription is renewed. return set() return SubscriptionEntitlementService.get_plan_access_codes(db, tenant_id) @@ -146,7 +134,6 @@ class SubscriptionEntitlementService: """ role_codes = SubscriptionEntitlementService.get_role_access_codes(user) - # A platform superadmin is not a tenant of anything and is not plan-bounded. if getattr(user, "is_superadmin", False): return role_codes @@ -206,9 +193,6 @@ class SubscriptionEntitlementService: "plan_id": str(tenant.plan_id) if tenant.plan_id else None, "plan_name": plan_name, "max_users_allowed": tenant.plan.max_users_allowed if tenant.plan else None, - # Reported alongside the limit so the console can show "3 of 5" and - # warn before someone hits the wall, rather than only refusing at the - # moment they try to add a colleague. "seats_used": seats.used, "seats_remaining": seats.remaining, "seats_over_limit": seats.over, @@ -217,7 +201,5 @@ class SubscriptionEntitlementService: "status": tenant.status, "is_active": tenant.is_active, "is_live": SubscriptionEntitlementService.is_subscription_live(tenant), - # So the interface can say "read-only until 14 March" rather than - # failing a save with a generic message. **subscription_lifecycle.summary(tenant), } diff --git a/app/services/auth/subscription_history.py b/app/services/auth/subscription_history.py index eac4c09..5675ece 100644 --- a/app/services/auth/subscription_history.py +++ b/app/services/auth/subscription_history.py @@ -100,9 +100,6 @@ def classify(db: Session, before: Snapshot, after: Snapshot) -> Optional[str]: return "status_change" if before.end_date != after.end_date: - # Pushing the end date out is a renewal; pulling it in is not, and - # calling both "renewal" would make the record useless for the case that - # actually needs explaining. if before.end_date and after.end_date and after.end_date > before.end_date: return "renewal" if before.end_date is None and after.end_date is not None: @@ -154,11 +151,6 @@ def record( logger.exception("Could not record subscription history for tenant %s", tenant.id) return None - # Emitted from here rather than from each caller, because this function is - # already the one place that decides whether anything actually changed — - # `classify` returning None is the difference between a plan change and a - # save with no edits. Emitting at the call sites would mean re-deciding that - # in four places, differently. from app.services.system import webhook_events, webhook_service webhook_service.emit( diff --git a/app/services/auth/subscription_lifecycle.py b/app/services/auth/subscription_lifecycle.py index dc88128..84a7bea 100644 --- a/app/services/auth/subscription_lifecycle.py +++ b/app/services/auth/subscription_lifecycle.py @@ -44,18 +44,13 @@ class SubscriptionState(str, enum.Enum): EXPIRED = "EXPIRED" CANCELLED = "CANCELLED" SUSPENDED = "SUSPENDED" - NONE = "NONE" # no plan attached; not a fault + NONE = "NONE" -# Administrative states, which no date can override. A suspended workspace does -# not become active again because its end date is in the future. _ADMINISTRATIVE = { "SUSPENDED": SubscriptionState.SUSPENDED, "CANCELLED": SubscriptionState.CANCELLED, "INACTIVE": SubscriptionState.SUSPENDED, - # A stored EXPIRED is also a decision — something concluded this workspace - # had lapsed. Honour it rather than second-guessing from the dates, which - # may not have been updated at the same moment. "EXPIRED": SubscriptionState.EXPIRED, } @@ -112,7 +107,6 @@ def resolve(tenant: Tenant | None) -> Lifecycle: stored = (tenant.status or "").strip().upper() - # An administrator's decision outranks the calendar. if stored in _ADMINISTRATIVE: return Lifecycle(_ADMINISTRATIVE[stored]) if not tenant.is_active and stored != "EXPIRED": @@ -121,16 +115,11 @@ def resolve(tenant: Tenant | None) -> Lifecycle: end = _as_date(tenant.end_date) if end is None: - # No end date: bounded only by whether a plan exists at all. return Lifecycle( SubscriptionState.ACTIVE if tenant.plan_id else SubscriptionState.NONE ) - # Dates are checked before the plan, deliberately. A workspace with an end - # date and no plan is still past its end date — treating it as "no plan, so - # nothing to expire" would hand it indefinite access. now = today() - # Inclusive: the end date is the last day that works. if now <= end: return Lifecycle(SubscriptionState.ACTIVE) diff --git a/app/services/auth/subscription_notices.py b/app/services/auth/subscription_notices.py index 6bb793c..08ca2b9 100644 --- a/app/services/auth/subscription_notices.py +++ b/app/services/auth/subscription_notices.py @@ -41,8 +41,6 @@ EXPIRING_SOON = "expiring_soon" GRACE_STARTED = "grace_started" EXPIRED = "expired" -# How far ahead to warn. Long enough to renew through a purchasing process, -# short enough that the notice is still about something imminent. WARN_DAYS_BEFORE = 7 @@ -78,9 +76,6 @@ def _due_kind(tenant: Tenant, today: date) -> Optional[str]: lifecycle = subscription_lifecycle.resolve(tenant) if lifecycle.state is SubscriptionState.ACTIVE: - # Warn from the threshold onwards rather than on one exact day: a worker - # that misses a run — an outage, a deploy — would otherwise skip the - # notice entirely, and the once-per-cycle key makes catching up safe. return EXPIRING_SOON if end - today <= timedelta(days=WARN_DAYS_BEFORE) else None if lifecycle.state is SubscriptionState.GRACE: @@ -89,9 +84,6 @@ def _due_kind(tenant: Tenant, today: date) -> Optional[str]: if lifecycle.state is SubscriptionState.EXPIRED: return EXPIRED - # SUSPENDED and CANCELLED are administrative decisions somebody already - # communicated. Emailing about them would be the platform announcing a - # conversation it was not part of. return None @@ -184,7 +176,6 @@ def record(db: Session, notice: Notice) -> bool: db.flush() return True except IntegrityError: - # Another worker got there first. Not an error. db.rollback() return False @@ -202,8 +193,6 @@ def send(db: Session, notices: Optional[Iterable[Notice]] = None) -> dict: continue if not notice.deliverable: - # Recorded, not sent: a workspace with no billing address is a gap - # worth seeing rather than a silent no-op. unreachable += 1 logger.warning( "No billing_email for workspace %s (%s); " @@ -216,8 +205,6 @@ def send(db: Session, notices: Optional[Iterable[Notice]] = None) -> dict: if EmailService.send_notice(notice.to, subject, body): sent += 1 else: - # The record stands. A failed send is a delivery problem, and - # retrying it every hour would eventually deliver a burst. logger.error( "Could not send %s notice to %s for workspace %s", notice.kind, notice.to, notice.tenant_name, diff --git a/app/services/auth/tenant_module_service.py b/app/services/auth/tenant_module_service.py index b9f100e..4a80905 100644 --- a/app/services/auth/tenant_module_service.py +++ b/app/services/auth/tenant_module_service.py @@ -110,8 +110,6 @@ class TenantModuleService: update_dict = update_data.model_dump(exclude_unset=True) - # The edit path is the one people actually use, so it needs the same - # check as the create path. if "assigned_environment_slug" in update_dict: module = db.query(Module).filter( Module.id == tenant_module.module_id @@ -126,8 +124,6 @@ class TenantModuleService: for key, value in update_dict.items(): setattr(tenant_module, key, value) - # The column existed and nothing ever wrote it, so "since when has this - # workspace not had the module" had no answer. if was_active and tenant_module.is_active is False: tenant_module.deactivated_at = datetime.now(timezone.utc) elif tenant_module.is_active: @@ -148,4 +144,4 @@ class TenantModuleService: raise HTTPException(status_code=404, detail="Tenant module assignment not found") db.delete(tenant_module) - db.commit() \ No newline at end of file + db.commit() diff --git a/app/services/auth/tenant_service.py b/app/services/auth/tenant_service.py index 9255136..34c2068 100644 --- a/app/services/auth/tenant_service.py +++ b/app/services/auth/tenant_service.py @@ -52,10 +52,6 @@ class TenantService: ) normalized_status = TenantService._normalize_status(status_value, is_active) - # Inclusive, matching `subscription_lifecycle`. This was `<=`, which made - # the final day of a subscription expired here and active there — the - # same customer locked out by the middleware and entitled by the - # permission check, on the same day. if end_date and end_date < TenantService._today(): return TenantService.STATUS_EXPIRED, False if normalized_status in { @@ -109,7 +105,6 @@ class TenantService: provisioning_id = str(uuid.uuid4()) try: - # 1. Create Tenant tenant = Tenant( tenant_name=tenant_data.tenant_name, tenant_domain=tenant_data.tenant_domain, @@ -231,9 +226,6 @@ class TenantService: ) logger.info(f"Event TENANT_PROVISION_REQUESTED emitted to outbox{' (with ROLE_PROVISION_REQUESTED follow-up)' if role_follow_up else ''}.") - # The opening entry. Without it the record starts mid-story, and - # "what plan did they sign up on" is unanswerable for every workspace - # that never changed plan. subscription_history.record( db, tenant, @@ -302,8 +294,6 @@ class TenantService: ) -> Tenant: tenant = TenantService.get_tenant_by_id(db, tenant_id) - # Taken before any mutation: the comparison is against what was actually - # stored, not against what the request claims was there. before = subscription_history.snapshot(tenant) update_dict = tenant_data.model_dump(exclude_unset=True) @@ -454,9 +444,6 @@ class TenantService: ) should_emit_status = True - # Cancellation is a decision with a date, not the absence of a flag. - # Reactivation clears it so the field never claims a workspace that is - # running was cancelled. if (tenant.status or "").strip().upper() == "CANCELLED": if tenant.cancelled_at is None: tenant.cancelled_at = datetime.now(timezone.utc) @@ -477,7 +464,6 @@ class TenantService: ] if broadcast_targets: - if should_emit_update: payload = { "tenant_id": str(tenant.id), @@ -552,7 +538,6 @@ class TenantService: ] if broadcast_targets: - payload = { "tenant_id": str(tenant.id), "tenant_name": tenant.tenant_name, @@ -566,9 +551,6 @@ class TenantService: tenant_id=tenant.id ) - # Marked, not removed. Every audit entry, subscription record and - # history row points at this row; deleting it makes all of them - # unattributable at once, and nothing brings them back. from datetime import datetime, timezone tenant.deleted_at = datetime.now(timezone.utc) @@ -590,7 +572,6 @@ class TenantService: sort_by: Optional[str] = None, sort_order: Optional[str] = None, ) -> TenantPaginatedResponse: - query = db.query(Tenant) if filter_tenant_names: diff --git a/app/services/auth/trust_service.py b/app/services/auth/trust_service.py index 82ef14e..c333394 100644 --- a/app/services/auth/trust_service.py +++ b/app/services/auth/trust_service.py @@ -12,9 +12,6 @@ from app.models.auth.module_environment_model import ModuleEnvironment logger = logging.getLogger(__name__) -# v1 signs the request body alone. v2 signs `timestamp.nonce.body`, which is what -# makes the timestamp and nonce worth anything — unsigned, a replayer would just -# rewrite them. SIGNATURE_V1 = "1" SIGNATURE_V2 = "2" @@ -42,9 +39,6 @@ class TrustService: detail="Invalid request timestamp", ) - # Accept seconds or milliseconds: the outbound handoff uses - # milliseconds, and a module implementing this direction from that - # example will reasonably do the same. if sent_at > 1e11: sent_at /= 1000.0 @@ -195,11 +189,6 @@ class TrustService: detail="Replay controls are required: send signature version 2", ) else: - # Accepted for now. A v1 request is replayable, which is harmless - # only because the one endpoint using this is idempotent by - # construction — grants are deleted on use. Logged so that moving - # to v2 is a decision somebody makes rather than one that never - # gets made. logger.info( "Module %s sent a version 1 signature: no replay protection.", environment.slug, @@ -212,8 +201,6 @@ class TrustService: detail="Invalid signature" ) - # Claimed only after the signature checks out, so an unauthenticated - # caller cannot burn a legitimate client's nonces. if version == SIGNATURE_V2: TrustService._claim_nonce(environment, nonce) @@ -230,4 +217,4 @@ class TrustService: raise HTTPException( status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=f"Trust type {environment.trust_type} not supported yet" - ) \ No newline at end of file + ) diff --git a/app/services/auth/user_service.py b/app/services/auth/user_service.py index ee6800f..4dc3a2e 100644 --- a/app/services/auth/user_service.py +++ b/app/services/auth/user_service.py @@ -25,7 +25,6 @@ from app.models.auth.tenant_model import Tenant logger = logging.getLogger(__name__) class UserService: - @staticmethod def _email_taken(db: Session, email: str, exclude_id=None) -> bool: """Is this address in use anywhere at all? @@ -37,8 +36,6 @@ class UserService: from app.core.tenant_context import unscoped with unscoped(): - # Same expression as the unique index, so the check and the - # constraint agree even about rows written before normalising. query = db.query(User.id).filter( func.lower(User.email) == normalise_email(email) ) @@ -49,16 +46,6 @@ class UserService: @staticmethod def create_user(db: Session, user_data: UserCreate, tenant_id: uuid.UUID = None, background_tasks: BackgroundTasks = None) -> User: - # Unscoped, because `users.email` is globally unique and the check that - # guards it has to see what the constraint sees. Scoped, row-level - # security hid the conflicting row, the check passed, and the INSERT hit - # the index — the caller got a 500 with a database error where they - # should have got "that address is already in use". - # - # The message deliberately does not say where the address is in use. A - # global constraint makes some existence signal unavoidable; naming the - # workspace would turn it into a way to ask which customers a competitor - # has. if UserService._email_taken(db, user_data.email): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -71,9 +58,6 @@ class UserService: detail="Password too weak" ) - # S-7: max_users_allowed was stored, displayed and never checked. Enforced - # here, before the row is written, and under a lock so two administrators - # cannot both take the last seat. if (user_data.status or "active") == "active": SeatService.assert_seat_available(db, tenant_id) @@ -155,10 +139,6 @@ class UserService: ).filter(User.deleted_at.is_(None)) if tenant_id: query = query.filter(User.tenant_id == tenant_id) - # None means unrestricted, which is the default and what every - # administrator is today. An empty set means "scoped, and nothing in - # scope" — genuinely different, and it must return nothing rather than - # everything. if visible_ids is not None: query = query.filter(User.id.in_(visible_ids or {uuid.uuid4()})) return query.all() @@ -170,9 +150,6 @@ class UserService: query = db.query(User).filter(User.id == user_id) if not include_deleted: query = query.filter(User.deleted_at.is_(None)) - # 404 rather than 403 falls out of filtering here: outside a scoped - # administrator's branch, another person's account is indistinguishable - # from one that does not exist. if visible_ids is not None and user_id not in visible_ids: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" @@ -200,9 +177,6 @@ class UserService: if tenant_id: update_dict.pop("tenant_id", None) - # Re-enabling a disabled account takes a seat back. Without this the - # limit is trivially avoidable: disable someone, add someone else, - # re-enable the first. if update_dict.get("status") == "active" and user.status != "active": SeatService.assert_seat_available(db, user.tenant_id) @@ -223,8 +197,6 @@ class UserService: update_dict["email"] = normalise_email(update_dict["email"]) if "email" in update_dict and update_dict["email"] != user.email: - # Unscoped for the same reason as create_user: the constraint is - # global, so the check has to be. if UserService._email_taken(db, update_dict["email"], exclude_id=user.id): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -262,9 +234,6 @@ class UserService: "last_name": user.last_name, "role_id": str(user.role_id) if user.role_id else None, "status": user.status, - # Named rather than diffed: a receiver that only cares about - # role changes should not have to keep its own copy of every - # field to work out whether one happened. "role_changed": role_changed, }, ) @@ -382,10 +351,6 @@ class UserService: user.deleted_at = datetime.now(timezone.utc) user.deleted_by_id = actor_id - # Their sessions go, which a hard delete used to do by cascade. A - # refresh token that outlives the account is the kind of dangling state - # that eventually gets resolved in the permissive direction — and unlike - # the account itself, a session record is worth nothing after the fact. from app.core.tenant_context import unscoped from app.models.system.user_session_model import UserSession @@ -466,14 +431,11 @@ class UserService: ) -> UserPaginatedResponse: query = db.query(User).outerjoin(Tenant, User.tenant_id == Tenant.id).outerjoin(Role, User.role_id == Role.id) - # A deleted account is gone from every direction that matters, and this - # is the list an administrator actually looks at. query = query.filter(User.deleted_at.is_(None)) if visible_ids is not None: query = query.filter(User.id.in_(visible_ids or {uuid.uuid4()})) - # Scope to tenant if not superadmin if tenant_id: query = query.filter(User.tenant_id == tenant_id) @@ -607,4 +569,4 @@ class UserService: "environment_slug": env_slug }) - return targets \ No newline at end of file + return targets diff --git a/app/services/system/alerting.py b/app/services/system/alerting.py index 3e2c9f7..80aa219 100644 --- a/app/services/system/alerting.py +++ b/app/services/system/alerting.py @@ -73,9 +73,6 @@ def _now() -> datetime: return datetime.now(timezone.utc) -# --------------------------------------------------------------- the checks - - def _stuck_events(db: Session) -> Condition: """Pending, overdue, and already retried at least once. @@ -154,9 +151,6 @@ CHECKS: tuple[Callable[[Session], Condition], ...] = ( ) -# ------------------------------------------------------------- the dispatch - - def _post_webhook(payload: dict) -> bool: if not settings.ALERT_WEBHOOK_URL: return False @@ -208,9 +202,6 @@ def dispatch(condition: Condition, *, resolved: bool = False) -> bool: return delivered -# ----------------------------------------------------------------- the loop - - def _should_renotify(state: AlertState) -> bool: if state.last_notified_at is None: return True @@ -221,14 +212,10 @@ def _should_renotify(state: AlertState) -> bool: def evaluate(db: Session) -> dict: """Run every check, open and close conditions, and send what is due.""" if not settings.ALERT_WEBHOOK_URL and not settings.ALERT_EMAIL: - # Nowhere to send. Checked here rather than in the worker so that turning - # alerting on is one setting rather than a setting and a deployment. return {"configured": False} opened = renotified = resolved = 0 - # Unscoped: these are platform-wide counts, and the loop runs as a worker - # with no request context to inherit. with unscoped(): for check in CHECKS: condition = check(db) @@ -243,8 +230,6 @@ def evaluate(db: Session) -> dict: state = AlertState(alert_key=condition.key) db.add(state) elif state.resolved_at is not None: - # Came back. Reopened rather than left closed, so the - # recurrence is visible as a new incident. state.resolved_at = None state.opened_at = _now() state.last_notified_at = None @@ -262,14 +247,9 @@ def evaluate(db: Session) -> dict: opened += 1 else: renotified += 1 - # Not delivered: left un-notified on purpose, so the next - # pass tries again rather than the outage becoming silence. elif state is not None and state.resolved_at is None: state.resolved_at = _now() - # Only announce recovery for something that was actually - # announced. Closing a condition nobody was told about should - # not produce an all-clear for a problem they never heard of. if state.notify_count: dispatch(condition, resolved=True) resolved += 1 diff --git a/app/services/system/audit_log_service.py b/app/services/system/audit_log_service.py index 91d4fc2..8300204 100644 --- a/app/services/system/audit_log_service.py +++ b/app/services/system/audit_log_service.py @@ -36,10 +36,6 @@ class AuditLogService: default for an action taken by the platform on a workspace's behalf, and the safe one for an action whose owner is unclear. """ - # An action taken through an API key is attributed to the key's owner — - # they are accountable for it — but "which integration did this" is the - # first question asked when something unexpected appears, and without - # this the answer is indistinguishable from the person doing it by hand. from app.services.auth.api_key_service import acting_key_name via = acting_key_name() @@ -66,4 +62,4 @@ class AuditLogService: db.commit() except Exception as exc: db.rollback() - logger.error("AuditLogService.log failed: %s", exc, exc_info=True) \ No newline at end of file + logger.error("AuditLogService.log failed: %s", exc, exc_info=True) diff --git a/app/services/system/audit_retention.py b/app/services/system/audit_retention.py index 0c880a1..a7364a0 100644 --- a/app/services/system/audit_retention.py +++ b/app/services/system/audit_retention.py @@ -38,22 +38,14 @@ from app.core.tenant_context import unscoped logger = logging.getLogger(__name__) -# Ordinary entries. Long enough for "what happened last quarter" and for most -# contractual retention commitments. DEFAULT_RETENTION_DAYS = 365 -# Anything a security investigation would want. A small share of the volume, and -# the part somebody reconstructs an incident from. -SECURITY_RETENTION_DAYS = 1095 # three years +SECURITY_RETENTION_DAYS = 1095 SECURITY_MODULES = ("Security", "SSO", "Webhooks") -# A floor no configuration can go under. A sweep that empties the table is not -# recoverable, and a zero in a settings file is an ordinary mistake. MINIMUM_RETENTION_DAYS = 30 -# One DELETE covering two years takes a lock long enough to stall the writes that -# are themselves audit entries. BATCH_SIZE = 5000 MAX_BATCHES_PER_RUN = 20 @@ -95,16 +87,6 @@ def _retention_connection(): engine = create_engine(url, pool_pre_ping=True) session = sessionmaker(bind=engine)() try: - # Set on the connection rather than through `unscoped()`. - # - # `unscoped()` sets a ContextVar that the application's session listener - # reads; this session has no such listener, so the flag would never reach - # PostgreSQL and every policy would hide every row — a sweep that deleted - # nothing and reported success. - # - # Session-scoped rather than transaction-scoped because `purge` commits - # between batches, which would reset a local setting halfway through. The - # connection is dedicated to this job and disposed at the end of it. from sqlalchemy import text as _text session.execute(_text("SELECT set_config('app.bypass_rls', 'on', false)")) @@ -154,9 +136,6 @@ def _purge_with(db: Session, retention_days: int, removed = 0 with unscoped(): for _ in range(MAX_BATCHES_PER_RUN): - # Written as SQL because the delete has to be bounded by a subquery - # — `DELETE ... LIMIT` is not valid PostgreSQL, and the ORM's - # `.limit().delete()` silently drops the limit. result = db.execute( text( """ diff --git a/app/services/system/document_service.py b/app/services/system/document_service.py index 29fe175..9c64f80 100644 --- a/app/services/system/document_service.py +++ b/app/services/system/document_service.py @@ -46,14 +46,8 @@ from app.models.system.document_model import Document logger = logging.getLogger(__name__) -# Enough to hold every signature `file_types` looks at, and small enough that -# reading it costs nothing. SNIFF_BYTES = 1024 -# What an `entity_type` is allowed to be. An allow-list rather than free text: -# without it the column becomes whatever any caller writes, and "show me -# everything attached to this invoice" stops being answerable the first time two -# callers spell it differently. ENTITY_TYPES = ("user", "tenant", "role", "subscription", "workspace") @@ -107,7 +101,6 @@ def upload( detail="Unknown attachment target: " + entity_type, ) if (entity_type is None) != (entity_id is None): - # One without the other is an attachment to nothing, or to everything. raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="An attachment needs both a target type and a target id", @@ -118,9 +111,6 @@ def upload( head = source.read(SNIFF_BYTES) content_type = _accept(head, clean_name) - # Checked before writing so an obviously-over-quota upload does not spend the - # disk first. Rechecked after, because two uploads arriving together both see - # room here. already = used_bytes(db, tenant_id) if already >= settings.DOCUMENT_QUOTA_BYTES: raise HTTPException( @@ -157,8 +147,6 @@ def upload( ) if already + size > settings.DOCUMENT_QUOTA_BYTES: - # The recheck. The bytes are already on disk, so they come off again — - # leaving them would charge the workspace for a file it does not have. document_storage.delete(key) raise HTTPException( status_code=status.HTTP_409_CONFLICT, @@ -181,8 +169,6 @@ def upload( db.add(document) db.flush() except Exception: - # The row failed; the bytes must not survive it, or the workspace is - # charged for something nothing points at. document_storage.delete(key) raise diff --git a/app/services/system/idempotency_service.py b/app/services/system/idempotency_service.py index 8f14f2e..07c291d 100644 --- a/app/services/system/idempotency_service.py +++ b/app/services/system/idempotency_service.py @@ -53,17 +53,10 @@ from app.models.system.idempotency_model import IdempotencyRecord logger = logging.getLogger(__name__) -# A retry happens in seconds or minutes. A day is generous; a week would mean a -# client generating keys per hour eventually collides with its own history. RETENTION_HOURS = 24 -# Long enough for any legitimate key — a UUID is 36 — and short enough that a -# client cannot use the column as storage. MAX_KEY_LENGTH = 255 -# Bodies larger than this are not remembered. A replayed response has to be -# stored somewhere, and an endpoint returning megabytes is one where the retry -# is cheaper than the row. MAX_STORED_BODY_BYTES = 64 * 1024 @@ -116,9 +109,6 @@ def claim( if existing is not None: if existing.is_expired: - # Long past. Treated as a fresh key rather than a conflict: the - # client has clearly moved on, and refusing would be a puzzle - # with no way to resolve it. db.delete(existing) db.flush() elif existing.request_hash != request_hash: @@ -150,9 +140,6 @@ def claim( db.flush() except IntegrityError: db.rollback() - # Lost the race to a request that arrived a moment earlier. This is - # the concurrency case the unique index exists for, and the honest - # answer is the same one that request's twin would have received. raise Conflict( 409, "A request with this Idempotency-Key is still being processed. " @@ -201,8 +188,6 @@ def complete( try: record.response_body = json.loads(body) if body else None except ValueError: - # Not JSON. Nothing useful to replay, so the key is released rather - # than kept against a response that cannot be reproduced. db.delete(record) db.commit() return diff --git a/app/services/system/lookup_service.py b/app/services/system/lookup_service.py index f73be4d..93bebac 100644 --- a/app/services/system/lookup_service.py +++ b/app/services/system/lookup_service.py @@ -64,9 +64,6 @@ def _normalise_code(code: str) -> str: return cleaned -# ------------------------------------------------------------------- lists - - def visible_lists(db: Session, tenant_id: Optional[uuid.UUID]) -> list[LookupList]: """The platform's lists and this workspace's, together. @@ -196,9 +193,6 @@ def delete_list(db: Session, record: LookupList) -> None: db.flush() -# ------------------------------------------------------------------- items - - def items_in( db: Session, record: LookupList, @@ -237,8 +231,6 @@ def add_item( detail="A label is required") if record.tenant_id is None and tenant_id is not None: - # A workspace adding to a platform list — the ordinary case, and the one - # the flag exists to refuse where it makes no sense. if not record.allows_custom_items: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -308,9 +300,6 @@ def retire_item(db: Session, item: LookupItem) -> LookupItem: return item -# ------------------------------------------------------------------ seeding - - def seed_platform_list( db: Session, *, diff --git a/app/services/system/notification_service.py b/app/services/system/notification_service.py index 10c3a17..90306c8 100644 --- a/app/services/system/notification_service.py +++ b/app/services/system/notification_service.py @@ -50,8 +50,6 @@ from app.models.system.notification_preference_model import ( logger = logging.getLogger(__name__) -# Kinds. A short machine-readable string beside the human text, so a client can -# pick an icon or route a click without parsing a sentence. WEBHOOK_DISABLED = "webhook.disabled" INVITATION_ACCEPTED = "invitation.accepted" API_KEY_ISSUED = "api_key.issued" @@ -60,8 +58,6 @@ MFA_DISABLED = "security.mfa_disabled" ACCOUNT_LOCKED = "security.account_locked" SUBSCRIPTION_EXPIRING = "subscription.expiring" -# Every kind, in the order a settings screen should show them. Security notices -# come first because they are the ones somebody would regret turning off. KINDS: tuple[str, ...] = ( ACCOUNT_LOCKED, MFA_ENABLED, @@ -72,7 +68,6 @@ KINDS: tuple[str, ...] = ( SUBSCRIPTION_EXPIRING, ) -# The same kind about the same thing inside this window is one event, not many. DEDUPE_MINUTES = 60 RETENTION_DAYS = 90 @@ -189,9 +184,6 @@ def notify( already happened. """ if not wants(db, user_id, kind): - # Asked before the deduplication window is consulted: somebody who has - # turned a kind off should leave no trace of it at all, not a suppressed - # row that a later change of mind would then dedupe against. return None try: diff --git a/app/services/system/tenant_email_service.py b/app/services/system/tenant_email_service.py index 14dcd49..486ca0d 100644 --- a/app/services/system/tenant_email_service.py +++ b/app/services/system/tenant_email_service.py @@ -46,9 +46,6 @@ logger = logging.getLogger(__name__) CONNECT_TIMEOUT_SECONDS = 15 -# Ports that are actually SMTP. Not a security control on its own — the address -# check is — but a workspace pointing this at 22 or 3306 has made a mistake -# worth catching at the form rather than in a stack trace. ALLOWED_PORTS = (25, 465, 587, 2525) @@ -132,9 +129,6 @@ def send( settings.smtp_host, settings.smtp_port, timeout=CONNECT_TIMEOUT_SECONDS, ) - # Opportunistic rather than required: some internal relays on port - # 25 do not offer it, and refusing would make this unusable for - # exactly the customers most likely to run their own. try: server.starttls() except smtplib.SMTPException: @@ -149,9 +143,6 @@ def send( server.sendmail(settings.from_address, to_email, message.as_string()) return None except Exception as e: - # The exception text, not a generic message: "authentication failed" and - # "connection refused" call for different fixes, and the customer is the - # only one who can make either. return f"{type(e).__name__}: {e}"[:500] finally: if server is not None: diff --git a/app/services/system/webhook_service.py b/app/services/system/webhook_service.py index 4491bed..4a8f0a8 100644 --- a/app/services/system/webhook_service.py +++ b/app/services/system/webhook_service.py @@ -68,19 +68,11 @@ logger = logging.getLogger(__name__) SIGNATURE_VERSION = "v1" HTTP_TIMEOUT_SECONDS = 10 -# Ten attempts over roughly a day, the same shape as the module outbox. Long -# enough to ride out a deploy, short enough that a dead endpoint stops costing -# anything by the next morning. MAX_ATTEMPTS = 10 MAX_BACKOFF_SECONDS = 86400 -# An endpoint that has failed this many times in a row is not coming back on its -# own. Disabling it stops a dead URL consuming a delivery slot every minute, and -# — more importantly — makes the failure something the customer can see. FAILURES_BEFORE_DISABLING = 20 -# A response body is stored for diagnosis, not archived. Receivers return whole -# HTML error pages, and keeping them turns a bad afternoon into a disk problem. MAX_ERROR_LENGTH = 1000 DELIVERY_RETENTION_DAYS = 30 @@ -124,9 +116,6 @@ def emit( majority of workspaces and should stay cheap. """ if tenant_id is None: - # A platform-level action belongs to no workspace, so there is nobody to - # tell. Silently, because this is called from ordinary code paths that - # should not have to know whether a workspace is involved. return 0 with scoped_to(tenant_id): @@ -146,8 +135,6 @@ def emit( payload = { "event_id": str(event_id), "event_type": event_type, - # ISO 8601, UTC. A receiver sorting or ageing events needs this, and - # the delivery's own timestamp is about the attempt, not the event. "occurred_at": _now().isoformat(), "workspace_id": str(tenant_id), "data": data, @@ -158,9 +145,6 @@ def emit( WebhookDelivery( tenant_id=tenant_id, endpoint_id=endpoint.id, - # The same id to every endpoint: this is one event that - # happened once, and a customer correlating two receivers - # should see the same identifier in both. event_id=event_id, event_type=event_type, payload=payload, @@ -198,8 +182,6 @@ def deliver_due(db: Session, batch_size: int = 50) -> str: else: failed += 1 except Exception: - # Never expected — `_attempt` catches its own — but a raised - # exception here would abandon the rest of the batch. logger.exception("webhook delivery %s raised", delivery.id) failed += 1 finally: @@ -230,8 +212,6 @@ def _attempt(db: Session, delivery: WebhookDelivery) -> bool: ) if endpoint is None or not endpoint.is_active: - # Nothing to retry against. Marked rather than left pending, so it stops - # appearing in every batch for the next day. _give_up(delivery, "Endpoint is no longer active") return False @@ -243,9 +223,6 @@ def _attempt(db: Session, delivery: WebhookDelivery) -> bool: try: parts = urlsplit(endpoint.url) - # Re-resolved on every attempt. A host that was public when it was - # registered can point somewhere else by the time the event fires, and a - # check that only happens once is a check an attacker waits out. address = resolve_public_address(parts.hostname, parts.port) pinned = pin_url_to_address(endpoint.url, address) @@ -254,15 +231,9 @@ def _attempt(db: Session, delivery: WebhookDelivery) -> bool: content=body, headers={ "Content-Type": "application/json", - # The original name, because the connection goes to a literal - # address and TLS and virtual hosting both need the name. "Host": parts.netloc, "X-SaaS-Webhook-Signature": sign(secret, timestamp, body), "X-SaaS-Event-Type": delivery.event_type, - # A hint for cheap deduplication before parsing. The - # authoritative value is inside the signed body — a receiver - # trusting the header has handed an attacker the choice of - # idempotency key. "X-SaaS-Event-Id": str(delivery.event_id), "X-SaaS-Delivery-Attempt": str(delivery.attempts), "User-Agent": "saas-webhooks/1", @@ -271,8 +242,6 @@ def _attempt(db: Session, delivery: WebhookDelivery) -> bool: follow_redirects=False, ) except PrivateAddressError as e: - # Terminal rather than retried: the destination is not one this platform - # will ever connect to, so retrying is a slower way of never delivering. _give_up(delivery, f"Refused destination: {e}") _record_failure(db, endpoint, terminal=True) return False @@ -335,9 +304,6 @@ def _record_failure(db: Session, endpoint: WebhookEndpoint, "webhook endpoint %s disabled for tenant %s: %s", endpoint.id, endpoint.tenant_id, endpoint.disabled_reason, ) - # The whole reason notifications exist. Until this, an integration - # stopping was a line in a log file, and the customer found out when - # somebody noticed their data was stale. from app.services.system import notification_service notification_service.notify_administrators( diff --git a/app/services/system/worker.py b/app/services/system/worker.py index 61d9190..aa79dec 100644 --- a/app/services/system/worker.py +++ b/app/services/system/worker.py @@ -38,27 +38,12 @@ OUTBOX_INTERVAL_SECONDS = 5 ALERT_INTERVAL_SECONDS = 300 SESSION_SWEEP_INTERVAL_SECONDS = 3600 NOTICE_INTERVAL_SECONDS = 86400 -# Daily. The window is a year; there is nothing to gain from checking -# more often, and something to lose from holding the lock more often. AUDIT_PRUNE_INTERVAL_SECONDS = 86400 -# Customer webhooks. More often than the notices and less often than the module -# outbox: a customer expects an event within seconds, not milliseconds, and this -# batch talks to the open internet, where every attempt can cost ten seconds of -# timeout. WEBHOOK_INTERVAL_SECONDS = 10 -# Ended sessions are kept well past expiry on purpose: "you were signed out on -# the 3rd, from this address" is the useful half of a session record, and -# deleting it the moment the token dies throws that away. SESSION_RETENTION_DAYS = 30 -# ------------------------------------------------------------------ the jobs -# -# Each opens its own session and closes it. Sharing one across jobs would mean a -# failure in the first leaving the rest to work in a broken transaction. - - def run_outbox() -> str: from app.config.database import SessionLocal from app.services.auth.event_service import EventService @@ -98,13 +83,9 @@ def sweep_sessions() -> str: db, older_than_days=SESSION_RETENTION_DAYS ) abandoned = identity_provider_service.purge_expired_states(db) - # Long expired and never accepted. Accepted ones are kept: they are the - # record of where an account came from. stale_invites = invitation_service.purge_expired(db) old_deliveries = webhook_service.purge_old_deliveries(db) spent_keys = idempotency_service.purge_expired(db) - # Read ones only. Deleting something nobody has seen is - # deciding on their behalf that it did not matter. stale_notices = notification_service.purge_old(db) db.commit() @@ -153,14 +134,6 @@ def prune_audit_log() -> str: """ from app.services.system import audit_retention - # No session of the application's: the application role has UPDATE and - # DELETE revoked on audit_logs, and retention opens its own connection on a - # role that has exactly those two rights on that one table. - # - # Unconfigured, this raises rather than returning quietly. The scheduler - # records the failure, which is the whole point — a retention job that - # no-ops for a year while its last-run timestamp keeps updating is the - # failure nobody notices until the table is the reason a query times out. removed = audit_retention.purge() return f"pruned {removed} audit entr(ies)" if removed else "" @@ -214,9 +187,6 @@ JOBS: tuple[Job, ...] = ( ) -# --------------------------------------------------------------- scheduling - - @dataclass class Schedule: """When each job is next due. @@ -231,8 +201,6 @@ class Schedule: return now >= self.due.get(job.name, 0.0) def defer(self, job: Job, now: float) -> None: - # From now, not from when it was due: a job that overruns its interval - # should not immediately be due again and starve everything after it. self.due[job.name] = now + job.interval @@ -278,8 +246,5 @@ def run_forever(sleep_seconds: float = OUTBOX_INTERVAL_SECONDS) -> None: logger.info("Worker stopping.") return except Exception as e: - # tick() already guards each job, so reaching here means something - # outside them broke. Keep going rather than exiting: a worker that - # dies stops all four jobs, and nothing restarts it. logger.error(f"Worker loop error: {e}", exc_info=True) time.sleep(sleep_seconds) diff --git a/app/services/theme/color_palette_service.py b/app/services/theme/color_palette_service.py index b9466ca..155a725 100644 --- a/app/services/theme/color_palette_service.py +++ b/app/services/theme/color_palette_service.py @@ -54,11 +54,6 @@ class PaletteService: ColorPalette.id != palette_id ).update({"is_default": False}) elif palette.is_default: - # The console falls back to the default when a user has not - # chosen a palette, so clearing the flag on the last one leaves - # every page with no colours at all — from a toggle that looks - # like an ordinary preference. Promote the replacement instead; - # that demotes this one as a side effect. PaletteService._assert_another_default_exists(db, palette_id) palette.is_default = data.is_default @@ -88,10 +83,9 @@ class PaletteService: def delete_palette(db: Session, palette_id: UUID): palette = PaletteService.get_palette_by_id(db, palette_id) - # Same hazard as demoting it, through the other button. if palette.is_default: PaletteService._assert_another_default_exists(db, palette_id) db.delete(palette) db.commit() - return True \ No newline at end of file + return True diff --git a/scripts/backfill_audit_tenants.py b/scripts/backfill_audit_tenants.py index d2369bf..8cc0207 100644 --- a/scripts/backfill_audit_tenants.py +++ b/scripts/backfill_audit_tenants.py @@ -53,9 +53,6 @@ import os # noqa: E402 from sqlalchemy import create_engine, text # noqa: E402 from sqlalchemy.orm import sessionmaker # noqa: E402 -# Each entry: the module_name as written by the routes, and the SQL that resolves -# an entity_id to a workspace. Written as UPDATE ... FROM so the whole class is -# one statement rather than a row at a time. RESOLVERS = [ ( "Tenants", @@ -113,8 +110,6 @@ RESOLVERS = [ ), ] -# Platform objects. Named explicitly so the summary can distinguish "nothing to -# do here" from "we could not work it out". PLATFORM_MODULES = ("SubscriptionPlans", "Modules", "Module Environments") @@ -154,13 +149,6 @@ def main() -> int: engine = create_engine(args.database_url, pool_pre_ping=True) db = sessionmaker(bind=engine)() try: - # This reads and writes across every workspace by definition, which is - # exactly what the policy exists to prevent for ordinary code. - # - # Set on the connection rather than through `unscoped()`: that sets a - # ContextVar the application's session listener reads, and this session - # has no such listener — the flag would never reach PostgreSQL and every - # policy would hide every row, for a run that reported nothing to do. db.execute(text("SELECT set_config('app.bypass_rls', 'on', false)")) db.commit() @@ -171,10 +159,6 @@ def main() -> int: print("Nothing to do.") return 0 - # A dry run executes the same statements and rolls back, rather than - # counting with a parallel SELECT. Two queries that are supposed to - # match drift, and then the dry run reassures you about work the real - # run does differently. attributed = 0 for label, statement in RESOLVERS: result = db.execute(text(statement)) diff --git a/scripts/create_app_role.py b/scripts/create_app_role.py index 98ff06d..53894ba 100644 --- a/scripts/create_app_role.py +++ b/scripts/create_app_role.py @@ -33,8 +33,6 @@ from app.config.settings import settings # noqa: E402 DEFAULT_ROLE = "saas_app" -# Tables the application writes to. Granted explicitly rather than by wildcard so -# that a new table is a deliberate decision rather than an automatic grant. GRANT_STATEMENTS = ( 'GRANT CONNECT ON DATABASE "{database}" TO "{role}"', 'GRANT USAGE ON SCHEMA public TO "{role}"', @@ -45,17 +43,6 @@ GRANT_STATEMENTS = ( 'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO "{role}"', ) -# Applied *after* the grants above, and separate from them for that reason: the -# blanket ON ALL TABLES grant would otherwise hand these straight back. -# -# "Nobody, including an administrator, can alter or delete an audit record" was a -# rule the application remembered to follow. Any path that reached the database -# directly — a misused credential, a psql session, a compromised process — could -# rewrite the trail without leaving a mark in it, because the trail had no -# protection independent of the code that writes it. -# -# Retention still has to remove expired rows. It does so under a second, narrower -# role: see `scripts/create_audit_retention_role.py`. APPEND_ONLY_STATEMENTS = ( 'REVOKE UPDATE, DELETE ON audit_logs FROM "{role}"', ) @@ -98,14 +85,11 @@ def main() -> int: ) print(f"created role '{args.role}'") - # Belt and braces: these are the two attributes that would silently - # defeat every policy in the database. conn.execute(text(f'ALTER ROLE "{args.role}" NOSUPERUSER NOBYPASSRLS')) for statement in GRANT_STATEMENTS: conn.execute(text(statement.format(role=args.role, database=database))) - # After the grants, never before — see the comment on the constant. for statement in APPEND_ONLY_STATEMENTS: conn.execute(text(statement.format(role=args.role, database=database))) @@ -114,10 +98,6 @@ def main() -> int: {"r": args.role}, ).scalar() - # Checked rather than assumed, like the SUPERUSER test above. A revoke - # that silently did not hold — because the role inherits the privilege - # from PUBLIC or from a group — would leave the trail editable while this - # script reported success, which is the failure that matters most here. can_edit_audit = conn.execute( text( "SELECT has_table_privilege(:r, 'audit_logs', 'UPDATE') " diff --git a/scripts/create_audit_retention_role.py b/scripts/create_audit_retention_role.py index 699f81d..9fa9106 100644 --- a/scripts/create_audit_retention_role.py +++ b/scripts/create_audit_retention_role.py @@ -42,7 +42,6 @@ DEFAULT_ROLE = "saas_audit_retention" GRANT_STATEMENTS = ( 'GRANT CONNECT ON DATABASE "{database}" TO "{role}"', 'GRANT USAGE ON SCHEMA public TO "{role}"', - # The whole grant. No INSERT, no other table, no sequences. 'GRANT SELECT, DELETE ON audit_logs TO "{role}"', ) @@ -95,8 +94,6 @@ def main() -> int: for statement in GRANT_STATEMENTS: conn.execute(text(statement.format(role=args.role, database=database))) - # Re-applied every run: a later `GRANT ... ON ALL TABLES` to PUBLIC, or a - # role this one is granted into, could otherwise widen it silently. conn.execute(text(f'REVOKE INSERT, UPDATE ON audit_logs FROM "{args.role}"')) checks = conn.execute( diff --git a/scripts/create_ci_role.py b/scripts/create_ci_role.py index c2a7234..000baf2 100644 --- a/scripts/create_ci_role.py +++ b/scripts/create_ci_role.py @@ -31,8 +31,6 @@ sys.path.append(str(backend_path)) from sqlalchemy import create_engine, text # noqa: E402 ROLE = "saas_app" -# Fixed and weak on purpose: this role exists on throwaway databases only, and a -# generated password would have to be passed to the test run somehow. PASSWORD = "saas_app" @@ -51,8 +49,6 @@ def main() -> int: host = args.url.split("@")[-1].split("/")[0] if not any(host.startswith(local) for local in ("localhost", "127.0.0.1", "::1")): - # The role is created with a known password. Anywhere but a throwaway - # database, that is a credential somebody can use. print( f"Refusing to create a fixed-password role on {host!r}. " "Use scripts/create_app_role.py for a real deployment.", @@ -67,8 +63,6 @@ def main() -> int: ).scalar() if not exists: - # NOBYPASSRLS is the whole point: a role that can bypass makes the - # isolation tests pass without testing anything. connection.execute( text( f"CREATE ROLE {ROLE} LOGIN PASSWORD '{PASSWORD}' " @@ -90,8 +84,6 @@ def main() -> int: text(f"GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO {ROLE}") ) - # Tables created by a later migration would otherwise be unreachable - # until somebody re-ran the grants. connection.execute( text( "ALTER DEFAULT PRIVILEGES IN SCHEMA public " diff --git a/scripts/generate_module_key.py b/scripts/generate_module_key.py index 9605e92..9e3897f 100644 --- a/scripts/generate_module_key.py +++ b/scripts/generate_module_key.py @@ -45,8 +45,6 @@ def main() -> int: ).decode() if args.env: - # Newlines escaped: a PEM spans lines and a .env value does not. - # `settings` reads it back through the same escaping. one_line = private_pem.replace("\n", "\\n") print(f'SAAS_PRIVATE_KEY="{one_line}"') print(f"SAAS_KEY_ID={args.key_id}") diff --git a/scripts/measure_query_plans.py b/scripts/measure_query_plans.py index e706360..dcc292f 100644 --- a/scripts/measure_query_plans.py +++ b/scripts/measure_query_plans.py @@ -43,8 +43,6 @@ ADMIN_URL = "postgresql://postgres:postgres@localhost:5432/postgres" SCRATCH = "saas_scale" SCRATCH_URL = f"postgresql://postgres:postgres@localhost:5432/{SCRATCH}" -# Deliberately lopsided, because real data is: many small workspaces, one busy -# outbox, sessions outnumbering the people who own them. TENANTS = 2_000 USERS = 50_000 EVENTS = 200_000 @@ -78,8 +76,6 @@ def _seed(connection) -> None: {"n": USERS}, ) - # Spread evenly rather than by `ORDER BY random() LIMIT 1`, which the planner - # evaluates once and which therefore puts every user in one workspace. connection.execute( text( """ @@ -205,9 +201,6 @@ def main() -> int: scratch = create_engine(SCRATCH_URL, isolation_level="AUTOCOMMIT") - # Built from the models rather than by running the migrations: this measures - # the shape of the schema, and does not need the migration chain to be - # runnable against an unfamiliar database. from app.config.database import Base import app.models # noqa: F401 (registers every model) diff --git a/scripts/report_drift.py b/scripts/report_drift.py index 9866f8b..b30b88d 100644 --- a/scripts/report_drift.py +++ b/scripts/report_drift.py @@ -32,9 +32,6 @@ from sqlalchemy import text # noqa: E402 from app.config.database import SessionLocal # noqa: E402 from app.core.tenant_context import unscoped # noqa: E402 -# Workspaces over the seat limit their plan sets. Only active users count — -# a disabled account cannot sign in, so charging a seat for it would be wrong, -# and the enforcement counts the same way. SEATS = """ SELECT t.tenant_name, p.name AS plan_name, @@ -50,10 +47,6 @@ HAVING count(u.id) > p.max_users_allowed ORDER BY (count(u.id) - p.max_users_allowed) DESC """ -# Role grants a plan does not allow. Since finding S-2 a plan is an upper bound -# rather than an additional grant, so these are granted in the console and worth -# nothing at sign-in — which looks to the customer like a permission that does -# not work. ROLE_GRANTS_OUTSIDE_PLAN = """ SELECT t.tenant_name, r.role_name, @@ -86,8 +79,6 @@ SELECT t.tenant_name, ORDER BY t.tenant_name, r.role_name, ma.access_code """ -# Accounts belonging to no workspace and holding no platform privilege. The -# shape the old public-signup defect produced: they can sign in and see nothing. ORPHANED_ACCOUNTS = """ SELECT email, status, created_at FROM users @@ -96,8 +87,6 @@ SELECT email, status, created_at ORDER BY created_at """ -# Workspaces pinned to an environment their module does not have. The handoff -# falls back to the module's default, which is production. BAD_ENVIRONMENT_PINS = """ SELECT t.tenant_name, m.module_id, @@ -114,7 +103,6 @@ SELECT t.tenant_name, ORDER BY t.tenant_name """ -# Workspaces that will lapse with nobody to warn. NO_BILLING_CONTACT = """ SELECT tenant_name, end_date FROM tenants @@ -182,8 +170,6 @@ def main() -> int: db = SessionLocal() try: - # Unscoped: a platform-wide report by definition, run deliberately by an - # operator rather than reached through a request. with unscoped(): results = {name: _run(db, SECTIONS[name][1]) for name in chosen} finally: @@ -216,8 +202,6 @@ def main() -> int: for row in rows[:100]: print(" " + " ".join(str(row[h]).ljust(w) for h, w in zip(headers, widths))) if len(rows) > 100: - # Said out loud rather than silently truncated: a report that hides - # its own limit reads as "that is all of them". print(f" ... and {len(rows) - 100} more (use --json for all)") print() diff --git a/scripts/seed_palettes.py b/scripts/seed_palettes.py index 8a24a33..e02df31 100644 --- a/scripts/seed_palettes.py +++ b/scripts/seed_palettes.py @@ -11,7 +11,6 @@ sys.path.insert(0, str(backend_dir)) from app.config.database import SessionLocal from app.models.theme.color_palette_model import ColorPalette -# 1. Default Palette (Dark Navy Sidebar) DEFAULT_PALETTE = { "name": "Default Navy", "description": "The default theme with navy blue sidebar", @@ -47,7 +46,6 @@ DEFAULT_PALETTE = { } } -# 2. Light Palette LIGHT_PALETTE = { "name": "Clean White", "description": "Minimalist white theme", @@ -83,7 +81,6 @@ LIGHT_PALETTE = { } } -# 3. Dark Palette DARK_PALETTE = { "name": "Dark Mode", "description": "Dark theme for low light environments", @@ -150,4 +147,4 @@ def seed_palettes(): db.close() if __name__ == "__main__": - seed_palettes() \ No newline at end of file + seed_palettes() diff --git a/scripts/seed_superadmin.py b/scripts/seed_superadmin.py index 9cbab38..68f793c 100644 --- a/scripts/seed_superadmin.py +++ b/scripts/seed_superadmin.py @@ -23,9 +23,7 @@ from app.models.auth.access_model import Access from app.models.auth.role_model import Role from app.models.auth.role_access_model import RoleAccess -# Predefined accesses (access_code, category, name, parent_code) PREDEFINED_ACCESSES = [ - # Superadmin category ("superadmin.main.view", "Superadmin", "Allow access to superadmin view", None), ("superadmin.tenant.create", "Superadmin", "Allow access to create tenants", None), ("superadmin.tenant.read", "Superadmin", "Allow access to view all tenants", None), @@ -41,26 +39,21 @@ PREDEFINED_ACCESSES = [ ("superadmin.user.delete", "Superadmin", "Allow access to delete any user", None), ("superadmin.access.read", "Superadmin", "Allow access to view all accesses", None), - # Subscription Plans ("superadmin.plan.create", "Superadmin", "Allow access to create subscription plans", None), ("superadmin.plan.read", "Superadmin", "Allow access to view subscription plans", None), ("superadmin.plan.update", "Superadmin", "Allow access to update subscription plans", None), ("superadmin.plan.delete", "Superadmin", "Allow access to delete subscription plans", None), - # Theme/Palette ("superadmin.palette.read", "Superadmin", "Allow access to view color palettes", None), ("superadmin.palette.create", "Superadmin", "Allow access to create color palettes", None), ("superadmin.palette.update", "Superadmin", "Allow access to update color palettes", None), ("superadmin.palette.delete", "Superadmin", "Allow access to delete color palettes", None), - # Module Registry ("modules.view", "Superadmin", "Allow access to view module registry", None), ("modules.manage", "Superadmin", "Allow access to manage modules", None), ("tenants.manage", "Superadmin", "Allow access to manage tenant module assignments", None), - # All Accesses hereafter are applicable for a Tenant Admin - # Administration category ("admin.role.create", "Administration", "Allow access to create roles", None), ("admin.role.read", "Administration", "Allow access to view roles", None), ("admin.role.update", "Administration", "Allow access to update roles", None), @@ -71,14 +64,8 @@ PREDEFINED_ACCESSES = [ ("admin.user.delete", "Administration", "Allow access to delete users", None), ("admin.access.read", "Administration", "Allow access to view accesses", None), ("admin.logs.read", "Administration", "Allow access to view system audit logs", None), - # Separate from admin.user.*, because issuing a credential that outlives a - # session is not the same act as adding a colleague, and a workspace should - # be able to grant one without the other. ("admin.api_key.manage", "Administration", "Allow issuing and revoking API keys", None), ("admin.webhook.manage", "Administration", "Allow managing outbound webhooks", None), - # The identity-provider routes have required these since they were built and - # neither was ever seeded, so no role could be granted them and the SSO - # screens were unreachable by anybody except a superadmin. ("admin.sso.read", "Administration", "Allow viewing sign-in connections", None), ("admin.sso.manage", "Administration", "Allow configuring sign-in connections", None), ("admin.email.manage", "Administration", "Allow configuring outgoing email", None), @@ -94,7 +81,6 @@ def seed_accesses(db: Session): print("Seeding accesses...") created_count = 0 - # First pass: Create all accesses without parents for access_code, category, name, parent_code in PREDEFINED_ACCESSES: existing = db.query(Access).filter(Access.access_code == access_code).first() if not existing: @@ -109,7 +95,6 @@ def seed_accesses(db: Session): f" ✓ Created {created_count} new accesses (total: {len(PREDEFINED_ACCESSES)})" ) - # Second pass: Set parent relationships parent_count = 0 for access_code, category, name, parent_code in PREDEFINED_ACCESSES: if parent_code: @@ -193,7 +178,6 @@ def create_superadmin_user(db: Session, role: Role) -> bool: changed = True print(" ✓ Updated superadmin role") - # Superadmin is an explicit flag now, not an implied NULL tenant. if not existing_superadmin.is_superadmin: existing_superadmin.is_superadmin = True changed = True @@ -303,4 +287,4 @@ def main(): db.close() if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/scripts/verify_security_fixes.py b/scripts/verify_security_fixes.py index 7543f0b..0778e39 100644 --- a/scripts/verify_security_fixes.py +++ b/scripts/verify_security_fixes.py @@ -45,9 +45,6 @@ def check(finding: str, description: str): return wrap -# -------------------------------------------------------------------------- -# In-memory Redis stand-in -# -------------------------------------------------------------------------- class FakeRedis: def __init__(self): self.store: dict[str, str] = {} @@ -121,13 +118,9 @@ class FakePipeline: from app.core import redis as redis_module # noqa: E402 FAKE = FakeRedis() -# `client` is a read-only property over `_redis`; set the backing attribute. redis_module.sync_redis_client._redis = FAKE -# -------------------------------------------------------------------------- -# S-1 — public signup could join any workspace and minted superadmins -# -------------------------------------------------------------------------- @check("S-1", "signup no longer accepts a caller-chosen workspace header") def _s1_no_tenant_header(): from app import create_app @@ -197,9 +190,6 @@ def _s1_superadmin_explicit(): assert is_superadmin(S()) is True, "the explicit flag is not honoured" -# -------------------------------------------------------------------------- -# S-2 — plans granted permissions instead of bounding them -# -------------------------------------------------------------------------- @check("S-2", "plan bounds the role rather than adding to it") def _s2_intersection(): from app.services.auth import subscription_entitlement_service as m @@ -230,23 +220,19 @@ def _s2_intersection(): original = S.get_plan_bound try: - # A viewer on a plan carrying admin permissions must NOT get them. S.get_plan_bound = staticmethod( lambda db, tid: {"admin.user.read", "admin.user.delete", "report.view"} ) got = S.get_effective_access_codes(None, User({"report.view"})) assert got == {"report.view"}, f"expected role∩plan, got {got}" - # The role cannot exceed the plan either way round. got = S.get_effective_access_codes(None, User({"admin.user.delete", "nope"})) assert got == {"admin.user.delete"}, f"expected bounded set, got {got}" - # No plan means the role stands alone, not that nothing is permitted. S.get_plan_bound = staticmethod(lambda db, tid: UNBOUNDED) got = S.get_effective_access_codes(None, User({"report.view"})) assert got == {"report.view"}, f"unbounded case broken, got {got}" - # A lapsed subscription bounds to nothing. S.get_plan_bound = staticmethod(lambda db, tid: set()) got = S.get_effective_access_codes(None, User({"report.view"})) assert got == set(), f"expired plan should grant nothing, got {got}" @@ -258,9 +244,6 @@ def _s2_intersection(): def _s2_expiry(): from datetime import datetime, timedelta, timezone - # The application computes subscription boundaries in UTC. Using the - # machine's local date instead makes this check pass or fail depending on - # what time of day it runs — which is how a harness stops being believed. today = datetime.now(timezone.utc).date() from app.services.auth.subscription_entitlement_service import ( @@ -284,19 +267,12 @@ def _s2_expiry(): assert S.is_subscription_live(T(status="EXPIRED")) is False assert S.is_subscription_live(T(end=today - timedelta(days=1))) is False assert S.is_subscription_live(T(end=today + timedelta(days=1))) is True - # The last day is inclusive — the boundary the two implementations - # disagreed on before there was one authority. assert S.is_subscription_live(T(end=today)) is True - # Grace still entitles: the customer needs the interface in order to renew. assert S.is_subscription_live(T(end=today - timedelta(days=1), grace=7)) is True assert S.is_subscription_live(T(end=today - timedelta(days=8), grace=7)) is False - # No workspace at all is not a live subscription. assert S.is_subscription_live(None) is False -# -------------------------------------------------------------------------- -# S-3 — the signature covered four fields of the payload -# -------------------------------------------------------------------------- @check("S-3", "tampering with permissions invalidates the signature") def _s3_full_payload_signed(): from app.services.auth.trust_service import TrustService @@ -342,9 +318,6 @@ def _s3_replay_fields(): ) -# -------------------------------------------------------------------------- -# S-4 — HMAC verification fell back to an empty body -# -------------------------------------------------------------------------- @check("S-4", "the empty-body signature fallback is gone") def _s4_no_fallback(): src = (BACKEND / "app/controllers/auth/sso_controller.py").read_text( @@ -384,9 +357,6 @@ def _s4_empty_sig_rejected(): assert e.status_code == 401, f"expected 401, got {e.status_code}" -# -------------------------------------------------------------------------- -# S-5 — role lookups had no workspace filter -# -------------------------------------------------------------------------- @check("S-5", "role lookup requires explicit workspace scoping") def _s5_role_scoping(): import inspect @@ -409,9 +379,6 @@ def _s5_role_scoping(): ) -# -------------------------------------------------------------------------- -# S-8 — users could change their own status and email -# -------------------------------------------------------------------------- @check("S-8", "self-service profile update cannot set status or email") def _s8_profile_fields(): from app.schemas.auth.auth_schema import UserUpdate @@ -431,9 +398,6 @@ def _s8_allowlist(): ) -# -------------------------------------------------------------------------- -# S-9 — refresh tokens were never invalidated -# -------------------------------------------------------------------------- @check("S-9", "a rotated refresh token is rejected on reuse") def _s9_rotation(): from fastapi import HTTPException @@ -463,15 +427,10 @@ def _s9_logout(): assert "refresh_token" in parameters, "logout cannot revoke what it is not given" assert "REFRESH_TOKEN_SECRET" in src, "logout never touches the refresh secret" - # The blacklist is a cache and fails open when Redis is unreachable. Signing - # out has to reach the session row as well, or it does not survive a restart. assert "db" in parameters, "logout has no way to reach the session store" assert "revoke_by_jti" in src, "logout does not end the session record" -# -------------------------------------------------------------------------- -# S-10 — password reset trusted an email-keyed flag -# -------------------------------------------------------------------------- @check("S-10", "the pre-verified reset bypass is gone") def _s10_no_bypass(): src = (BACKEND / "app/services/auth/auth_service.py").read_text(encoding="utf-8") @@ -479,9 +438,6 @@ def _s10_no_bypass(): assert 'setex(f"otp_verified' not in src, "the bypass marker is still written" -# -------------------------------------------------------------------------- -# 1.8 — no rate limiting anywhere -# -------------------------------------------------------------------------- @check("1.8", "credential endpoints are rate limited") def _rl_wired(): from app import create_app @@ -519,9 +475,6 @@ def _rl_blocks(): assert retry is not None and retry > 0, "6th attempt was not blocked" -# -------------------------------------------------------------------------- -# B-1 — language preference was written nowhere and returned nowhere -# -------------------------------------------------------------------------- @check("B-1", "language preference round-trips") def _b1_language(): from app.schemas.auth.auth_schema import UserResponse, UserUpdate @@ -538,9 +491,6 @@ def _b1_language(): ) -# -------------------------------------------------------------------------- -# B-2 — a duplicated dict key dropped a header -# -------------------------------------------------------------------------- @check("B-2", "the SSO header dict has no duplicate key") def _b2_header(): src = (BACKEND / "app/services/auth/sso_service.py").read_text(encoding="utf-8") @@ -548,9 +498,6 @@ def _b2_header(): assert body.count('"X-App-Id"') == 1, "X-App-Id is still declared twice" -# -------------------------------------------------------------------------- -# S-6 — module trust secrets were stored in plaintext -# -------------------------------------------------------------------------- @check("S-6", "trust credentials encrypt and round-trip") def _s6_roundtrip(): from app.core.crypto import decrypt_json, encrypt_json, is_encrypted diff --git a/tests/conftest.py b/tests/conftest.py index 62e3576..dead5b2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,9 +28,6 @@ TEST_DATABASE_URL = os.environ.get( "TEST_DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/saas_test" ) -# Set before anything imports app.config.settings, and set in os.environ rather -# than passed as a parameter, because that is the only channel load_dotenv's -# override respects — it writes into os.environ, and last writer wins. os.environ["APP_ENV"] = "pytest" os.environ["DATABASE_URL"] = TEST_DATABASE_URL os.environ["REDIS_ENABLED"] = "False" @@ -47,15 +44,11 @@ def _assert_local(url: str) -> None: _assert_local(TEST_DATABASE_URL) -# Registered here as well as in create_app(), because service-level tests -# never build the application and would otherwise run with no context at -# all — passing or failing for reasons unrelated to what they assert. from app.core.rls import register_rls_listener # noqa: E402 register_rls_listener() - def utc_today(): """The date the *application* is working in. @@ -260,7 +253,7 @@ def access_factory(db): access = Access(access_code=code, category=category, name=code) db.add(access) db.flush() - return access # `accesses` carries no tenant_id, so no policy applies + return access return _make @@ -317,9 +310,6 @@ def user_factory(db): def _make(tenant=None, role=None, email: str | None = None, **overrides): with unscoped(): - # Read inside the bypass. `roles` is under row-level security, so a - # role handed in by a fixture and expired by an intervening commit - # re-loads to nothing here and SQLAlchemy reports it as deleted. role_id = role.id if role is not None else None tenant_id = tenant.id if tenant is not None else None @@ -365,8 +355,6 @@ def fake_redis(monkeypatch): self.expiry[k] = int(ttl) def set(self, k, v, ex=None, nx=False): - # nx is what makes a nonce claim atomic in the real client, so the - # stand-in has to honour it or the test proves nothing. if nx and k in self.store: return None self.store[k] = v @@ -426,15 +414,6 @@ def fake_redis(monkeypatch): return fake -# --------------------------------------------------------------------- modules -# -# The module registry and the SSO handoff are the least-covered surfaces in the -# application, and the handoff is the one that hands another system a signed -# statement about who a user is and what they may do. These build the smallest -# arrangement that exercises it: a module, an environment holding a trust secret, -# and the workspace's entitlement to reach it. - - @pytest.fixture def module_factory(db): from app.core.tenant_context import unscoped @@ -472,9 +451,6 @@ def environment_factory(db): is_default=overrides.pop("is_default", slug == "prod"), **overrides, ) - # Through the property, so the secret is encrypted at rest exactly as it - # is in production. Setting the column directly would test a shape that - # no longer exists. env.credentials = {"hmac_secret": secret} with unscoped(): db.add(env) @@ -582,13 +558,6 @@ def client(db, fake_redis): app = create_app() app.dependency_overrides[get_db] = lambda: db - # Two code paths deliberately open a session of their own rather than the - # request's: the tenant-scope middleware, which has to resolve an API key - # before the request's session exists, and the API-key `last_used_at` write, - # which must not commit whatever else the request is holding. Both would - # open a *real* connection here and see none of a test's uncommitted rows — - # so they are pointed at the test session, with close() neutered so one - # request cannot end the transaction the next one needs. import app.config.database as database_module class _SharedSession: @@ -606,9 +575,6 @@ def client(db, fake_redis): pass def commit(self): - # A side-session commit would end the fixture's transaction and - # leave the rows behind. Flushed instead: visible to this test, - # rolled back with everything else. self._inner.flush() original = database_module.SessionLocal diff --git a/tests/test_access_registry.py b/tests/test_access_registry.py index 82ec9c4..47cd604 100644 --- a/tests/test_access_registry.py +++ b/tests/test_access_registry.py @@ -22,9 +22,6 @@ def _codes(rows): return {row.access_code for row in rows} -# ------------------------------------------------------------------ the list - - def test_both_kinds_of_permission_appear(db, access_factory, module_factory, module_access_factory): """A picker showing only one of the two is a picker that cannot express half @@ -86,9 +83,6 @@ def test_the_category_list_is_stable(db, access_factory): assert first == sorted(first) -# --------------------------------------------------------------------- cache - - def test_the_list_is_served_from_cache_on_the_second_read(db, access_factory, fake_redis): access_factory("cached.thing") @@ -97,7 +91,6 @@ def test_the_list_is_served_from_cache_on_the_second_read(db, access_factory, AccessService.get_all_accesses(db) assert fake_redis.get("saas:access:v2:all:full") - # Served from the cache: the codes survive the round trip through JSON. again = AccessService.get_all_accesses(db) assert "cached.thing" in _codes(again) @@ -152,7 +145,6 @@ def test_a_sync_clears_the_category_caches_too(db, module_factory, ) with unscoped(): - # Warm both shapes of key. AccessService.get_all_accesses(db) AccessService.get_all_accesses(db, category="Reports") assert fake_redis.get("saas:access:v2:all:full") diff --git a/tests/test_access_seed.py b/tests/test_access_seed.py index 3c49891..14a9bd6 100644 --- a/tests/test_access_seed.py +++ b/tests/test_access_seed.py @@ -19,8 +19,6 @@ BACKEND = Path(__file__).resolve().parents[1] ROUTES = BACKEND / "app" / "routes" SEED = BACKEND / "scripts" / "seed_superadmin.py" -# Codes a module declares for itself, resolved at runtime from `module_accesses` -# rather than from the platform's own table. MODULE_PREFIXES = ("modules.",) @@ -58,7 +56,6 @@ def test_the_seed_has_no_permissions_nothing_asks_for(): seeded_admin = {code for code in _seeded() if code.startswith("admin.")} demanded = _demanded() - # Read by the console to decide what to show, rather than by a route. UI_ONLY = {"admin.access.read", "admin.logs.read"} orphans = sorted(seeded_admin - demanded - UI_ONLY) diff --git a/tests/test_alerting.py b/tests/test_alerting.py index 3d9d065..3f245f7 100644 --- a/tests/test_alerting.py +++ b/tests/test_alerting.py @@ -93,9 +93,6 @@ def _clear(db): db.flush() -# ------------------------------------------------------------------ firing - - def test_a_stuck_outbox_raises_an_alert(db, channel, stuck_events): _clear(db) stuck_events() @@ -180,9 +177,6 @@ def test_an_old_incident_stops_alerting(db, channel, tenant_factory, user_factor assert not any(a["key"] == "token_reuse" for a in channel) -# ------------------------------------------------------------- not repeating - - def test_an_open_condition_is_not_announced_again(db, channel, stuck_events): """The whole reason there is a state table.""" _clear(db) @@ -218,9 +212,6 @@ def test_it_speaks_up_again_after_the_cooldown(db, channel, stuck_events): assert len([a for a in channel if a["key"] == "outbox_stuck"]) == 2 -# ---------------------------------------------------------------- recovering - - def test_a_cleared_condition_says_so(db, channel, stuck_events): """Otherwise somebody chases a problem that fixed itself, and learns that the channel cannot be trusted to tell them when to stop.""" @@ -246,7 +237,7 @@ def test_nothing_recovers_that_was_never_announced(db, channel, stuck_events, stuck_events() channel.break_it() - alerting.evaluate(db) # opens, cannot deliver + alerting.evaluate(db) channel.fix_it() with unscoped(): @@ -276,9 +267,6 @@ def test_a_condition_that_comes_back_is_a_new_incident(db, channel, stuck_events assert len(firing) == 2 -# ----------------------------------------------------------- failing to alert - - def test_a_failed_send_is_retried_rather_than_forgotten(db, channel, stuck_events): """Marking it notified anyway would turn a webhook outage into silence about a real problem — the one outcome an alerting system must not produce.""" @@ -341,9 +329,6 @@ def test_email_alone_is_enough(db, monkeypatch, stuck_events): assert "outbox_stuck" in sent[0][1] -# ------------------------------------------------------------- through the API - - def test_open_alerts_are_visible_to_a_superadmin(client, db, channel, stuck_events, user_factory): """The console shows what is *open*, not what was sent: somebody arriving diff --git a/tests/test_api_keys.py b/tests/test_api_keys.py index 2939a91..6d0ac3c 100644 --- a/tests/test_api_keys.py +++ b/tests/test_api_keys.py @@ -60,9 +60,6 @@ def _as_key(raw: str) -> dict: return {"Authorization": f"Bearer {raw}"} -# --- the secret ----------------------------------------------------------- - - def test_the_key_is_returned_once_and_stored_as_a_hash(client, session_headers, db): created = _issue(client, session_headers) raw = created["key"] @@ -77,7 +74,6 @@ def test_the_key_is_returned_once_and_stored_as_a_hash(client, session_headers, assert raw not in row.key_hash assert row.key_hash == hash_secret(raw.split("_", 2)[2]) - # And no listing hands it back. listed = client.get("/api/api-keys", headers=session_headers).text assert raw not in listed @@ -115,9 +111,6 @@ def test_a_mistyped_or_unknown_key_is_refused(client): headers=_as_key(candidate)).status_code == 401 -# --- the ceiling ---------------------------------------------------------- - - def test_a_key_cannot_be_given_more_than_its_issuer_has(client, session_headers): response = client.post( "/api/api-keys", headers=session_headers, @@ -191,13 +184,9 @@ def test_a_key_cannot_mint_or_revoke_a_key(client, session_headers): json={"name": "second"}).status_code == 403 assert client.delete(f"/api/api-keys/{created['api_key']['id']}", headers=headers).status_code == 403 - # Reading the list is allowed; it holds no secrets. assert client.get("/api/api-keys", headers=headers).status_code == 200 -# --- the workspace boundary ---------------------------------------------- - - def test_a_key_sees_only_its_own_workspace(client, db, session_headers, tenant_factory, plan_factory, user_factory): @@ -228,8 +217,6 @@ def test_one_workspace_cannot_revoke_another_s_key(client, db, session_headers, json={"email": "stranger@example.com", "password": PASSWORD}).json()["access_token"] - # 404, not 403: another workspace's key id must be indistinguishable from - # one that never existed. assert client.delete(f"/api/api-keys/{mine['api_key']['id']}", headers={"Authorization": f"Bearer {token}"} ).status_code == 404 @@ -253,9 +240,6 @@ def test_managing_keys_needs_the_permission(client, db, tenant_factory, json={"name": "nope"}).status_code == 403 -# --- revocation and expiry ------------------------------------------------ - - def test_revoking_stops_a_key_immediately(client, session_headers): created = _issue(client, session_headers) headers = _as_key(created["key"]) @@ -264,7 +248,6 @@ def test_revoking_stops_a_key_immediately(client, session_headers): assert client.delete(f"/api/api-keys/{created['api_key']['id']}", headers=session_headers).status_code == 204 - # No cache, so no window in which a revoked key still works. assert client.get("/api/user/get", headers=headers).status_code == 401 @@ -343,12 +326,9 @@ def test_revoking_frees_a_slot(client, session_headers, monkeypatch): json={"name": "two"}).status_code == 201 -# --- the parser ----------------------------------------------------------- - - @pytest.mark.parametrize("candidate,expected", [ ("sk_abc_def", ("abc", "def")), - ("sk_abc_def_ghi", ("abc", "def_ghi")), # the secret may contain separators + ("sk_abc_def_ghi", ("abc", "def_ghi")), ("sk_abc", None), ("sk__def", None), ("abc_def", None), @@ -381,7 +361,6 @@ def test_the_audit_trail_says_which_key_acted(client, session_headers, db, ) assert entry is not None assert "nightly-import" in entry.description - # Still attributed to the owner: they are the one accountable for it. assert entry.performed_by_email == "integrator@example.com" diff --git a/tests/test_audit_immutability.py b/tests/test_audit_immutability.py index 9bcca22..af443fb 100644 --- a/tests/test_audit_immutability.py +++ b/tests/test_audit_immutability.py @@ -68,10 +68,6 @@ def an_entry(app_engine): """One audit row, written as the application — which is allowed to write.""" entry_id = uuid.uuid4() with app_engine.connect() as conn: - # `audit_logs` is under row-level security, and these connections are raw - # — nothing has set a workspace. The application sets the same flag for a - # platform-level entry, so this is the ordinary path rather than a test - # convenience. conn.execute(text("SELECT set_config('app.bypass_rls', 'on', false)")) conn.execute( text( @@ -81,7 +77,6 @@ def an_entry(app_engine): {"id": entry_id}, ) yield entry_id - # Removed as the retention role, which is the only one that can. engine = _connect(RETENTION_URL) if engine is not None: with engine.connect() as conn: @@ -90,9 +85,6 @@ def an_entry(app_engine): engine.dispose() -# --- what the application may and may not do ----------------------------- - - def test_the_application_can_write_an_entry(app_engine, an_entry): """The trail is append-only, not read-only. Writing is the whole point.""" with app_engine.connect() as conn: @@ -136,9 +128,6 @@ def test_the_revoke_is_visible_in_the_catalogue(app_engine): assert can_edit is False -# --- what the retention role may and may not do -------------------------- - - def test_retention_can_delete_an_entry(retention_engine, app_engine): """Otherwise the trail grows for ever, which is the problem retention exists for — and the roles would have made it unsolvable.""" @@ -209,9 +198,6 @@ def test_retention_is_not_privileged(retention_engine): assert privileged is False -# --- the job refuses rather than pretending ------------------------------ - - def test_retention_refuses_when_it_has_no_role_configured(monkeypatch): """A job that no-ops for a year while its last-run timestamp keeps updating is the failure nobody notices until the table is why a query times out.""" diff --git a/tests/test_audit_log.py b/tests/test_audit_log.py index 71e5ce8..feb9db3 100644 --- a/tests/test_audit_log.py +++ b/tests/test_audit_log.py @@ -52,15 +52,11 @@ def two_workspaces_with_history(db, tenant_factory, user_factory): new_values={"price": 999, "customer": "Bravo Corp"}, ) with unscoped(): - # An entry from before the column existed: unattributable. _write(db, None, email="platform@example.com", entity="Legacy Thing") return SimpleNamespace(alpha=alpha, bravo=bravo) -# ------------------------------------------------------------------- writing - - def test_an_entry_records_the_workspace_it_belongs_to(db, tenant_factory): tenant = tenant_factory() with scoped_to(tenant.id): @@ -88,7 +84,6 @@ def test_a_failed_write_does_not_break_the_action_it_describes(db, tenant_factor """Bookkeeping failing must never take down the thing being booked.""" tenant = tenant_factory() with scoped_to(tenant.id): - # `action_type` is capped at 20 characters; this is far over. AuditLogService.log( db=db, module_name="Tenants", @@ -98,9 +93,6 @@ def test_a_failed_write_does_not_break_the_action_it_describes(db, tenant_factor ) -# ------------------------------------------------------------------- reading - - @requires_enforced_rls def test_a_workspace_cannot_read_another_workspaces_history( db, two_workspaces_with_history @@ -154,9 +146,6 @@ def test_a_superadmin_sees_everything(db, two_workspaces_with_history): assert {"Alpha Thing", "Bravo Secret", "Legacy Thing"} <= names -# ------------------------------------------------------- through the endpoint - - def test_the_endpoint_shows_a_workspace_only_its_own_entries( client, db, tenant_factory, user_factory ): @@ -235,9 +224,6 @@ def test_a_superadmin_reads_across_workspaces_through_the_endpoint( assert "Alpha Thing" in {item["entity_name"] for item in body["items"]} -# ------------------------------------------------------------------ backfill - - @requires_audit_writes def test_historical_entries_are_given_back_to_their_workspaces( db, tenant_factory, role_factory, user_factory @@ -255,7 +241,6 @@ def test_historical_entries_are_given_back_to_their_workspaces( user = user_factory(tenant=tenant, role=role) with unscoped(): - # Written the way they were before the column existed. for module_name, entity_id in ( ("Tenants", tenant.id), ("Users", user.id), @@ -269,7 +254,6 @@ def test_historical_entries_are_given_back_to_their_workspaces( entity_id=str(entity_id), tenant_id=None, ) - # A platform object: no workspace to give it back to. AuditLogService.log( db=db, module_name="SubscriptionPlans", diff --git a/tests/test_audit_retention.py b/tests/test_audit_retention.py index 2d7ac63..4a49d7a 100644 --- a/tests/test_audit_retention.py +++ b/tests/test_audit_retention.py @@ -85,14 +85,10 @@ def retention_session(marker): pytest.skip("audit retention role not provisioned — " "run scripts/create_audit_retention_role.py") session = sessionmaker(bind=engine)() - # The same flag `audit_retention._retention_connection` sets, and for the - # same reason: this session has no RLS listener, so without it every policy - # hides every row and the sweep silently deletes nothing. session.execute(text("SELECT set_config('app.bypass_rls', 'on', false)")) session.commit() yield session session.close() - # Whatever the test left behind, tagged with its own marker. with engine.connect() as conn: conn.execute(text("SELECT set_config('app.bypass_rls', 'on', false)")) conn.execute( @@ -112,8 +108,6 @@ def _write(owner, marker, *, module="Users", age_days=0, suffix=""): "VALUES (:id, :module, 'UPDATE', :name, 'something happened', :created)" ), { - # `audit_logs.id` is generated by the model, not by the column — - # there is no server-side default — so a raw insert supplies one. "id": uuid.uuid4(), "module": module, "name": f"{marker}{suffix}", @@ -150,9 +144,6 @@ def tenant(tenant_factory): return tenant_factory() -# --- what the sweep removes ----------------------------------------------- - - def test_entries_past_the_window_go(owner, retention_session, marker): _write(owner, marker, age_days=400, suffix="-old") _write(owner, marker, age_days=10, suffix="-recent") @@ -237,9 +228,6 @@ def test_running_it_twice_removes_nothing_more(owner, retention_session, marker) assert audit_retention.purge(retention_session) == 0 -# --- the indexes behind the searches -------------------------------------- - - def test_the_trigram_indexes_exist(db): """The searches they serve are `ILIKE '%term%'`, which no B-tree can help with — so each keystroke in a search box was a sequential scan.""" diff --git a/tests/test_auth_api.py b/tests/test_auth_api.py index db610a3..d875f43 100644 --- a/tests/test_auth_api.py +++ b/tests/test_auth_api.py @@ -15,9 +15,6 @@ from .conftest import requires_db pytestmark = requires_db -# --------------------------------------------------------------------- signup - - def test_public_signup_is_refused_by_default(client): """S-1. Signup took the workspace from a request header, and the frontend sent none — so every real signup produced a tenant-less account, which the @@ -41,9 +38,6 @@ def test_signup_ignores_a_workspace_header(client): assert response.status_code == 403 -# --------------------------------------------------------------------- signin - - def test_signin_returns_a_session_and_the_effective_permissions( client, tenant_factory, plan_factory, role_factory, user_factory ): @@ -60,7 +54,6 @@ def test_signin_returns_a_session_and_the_effective_permissions( body = response.json() assert body["access_token"] and body["refresh_token"] - # Bounded by the plan, not unioned with it. assert body["user"]["role"]["accesses"] == ["reports.view"] assert body["user"]["is_superadmin"] is False @@ -97,8 +90,6 @@ def test_a_suspended_workspace_cannot_sign_its_users_in( "/api/auth/signin", json={"email": "suspended@example.com", "password": "CorrectHorse!9"}, ) - # Signing in succeeds; the workspace check happens on the first authenticated - # request. Pinning both halves so a change to either is visible. assert signin.status_code == 200 me = client.get( "/api/auth/me", @@ -107,9 +98,6 @@ def test_a_suspended_workspace_cannot_sign_its_users_in( assert me.status_code == 403 -# ------------------------------------------------------------------- profile - - def test_a_user_cannot_change_their_own_status( client, tenant_factory, user_factory ): @@ -127,8 +115,6 @@ def test_a_user_cannot_change_their_own_status( json={"status": "superuser"}, headers={"Authorization": f"Bearer {token}"}, ) - # Rejected outright rather than silently ignored — a caller who thinks they - # changed something should be told they did not. assert response.status_code in (400, 422) @@ -193,9 +179,6 @@ def test_one_user_cannot_edit_another(client, tenant_factory, user_factory): assert response.status_code == 403 -# -------------------------------------------------------------------- session - - def test_a_refresh_token_cannot_be_reused_after_rotation( client, tenant_factory, user_factory ): diff --git a/tests/test_deletion_blast_radius.py b/tests/test_deletion_blast_radius.py index 2a92e85..7ca9f19 100644 --- a/tests/test_deletion_blast_radius.py +++ b/tests/test_deletion_blast_radius.py @@ -34,9 +34,6 @@ from .conftest import requires_db, utc_today pytestmark = requires_db -# ------------------------------------------------- things that may be destroyed - - def test_a_roles_permission_grants_go_with_the_role(db, tenant_factory, role_factory): """A grant only exists to describe the role. Keeping it would leave rows @@ -87,9 +84,6 @@ def test_a_users_sessions_go_with_the_user(db, tenant_factory, user_factory): assert db.query(UserSession).filter(UserSession.user_id == user_id).count() == 0 -# ------------------------------------------- things that must survive, or refuse - - def test_a_workspace_with_members_refuses_to_be_deleted(db, tenant_factory, user_factory): """`Tenant.users` cascades delete-orphan: this used to remove the accounts.""" @@ -246,9 +240,6 @@ def test_an_environment_nobody_is_pinned_to_can_be_deleted( ).count() == 0 -# ----------------------------------------------------- history outlives its subject - - def test_subscription_history_survives_the_plan_it_describes(db, tenant_factory, plan_factory): """`SET NULL`, deliberately. History that vanishes with the thing it @@ -353,9 +344,6 @@ def test_notices_outlive_the_workspace_they_were_sent_about(db, tenant_factory, ).count() == 1, "the record of what we sent should survive the account" -# ------------------------------------------------------------------ the ratchet - - def test_every_delete_orphan_cascade_is_accounted_for(): """The one that stops this list going stale. @@ -385,36 +373,18 @@ def test_every_delete_orphan_cascade_is_accounted_for(): import app.models.system.subscription_notice_model # noqa: F401 import app.models.system.user_session_model # noqa: F401 - # Each entry is a decision, not an inventory. The comment says why the child - # is safe to destroy with its parent. ACCOUNTED_FOR = { - # Join rows: they only describe the parent. ("SubscriptionPlan", "plan_accesses"), ("SubscriptionPlan", "plan_module_accesses"), ("Role", "role_accesses"), ("Role", "role_module_accesses"), - # A module's own configuration and declared permissions. Guarded: the - # delete refuses while any workspace holds the module or any role holds - # one of its permissions. ("Module", "environments"), ("Module", "module_accesses"), ("Module", "tenant_modules"), - # A workspace's contents. Guarded: the delete refuses while it has - # members. ("Tenant", "users"), ("Tenant", "roles"), ("Tenant", "tenant_modules"), - # Delivery attempts against an endpoint. Safe to destroy with it, and - # deliberately unguarded: an endpoint being removed is the customer - # saying "stop sending here", and refusing while a week of delivery - # history exists would make removal impossible on any busy workspace. - # The audit log keeps the record that the endpoint was removed, and by - # whom, which is the part that has to survive. ("WebhookEndpoint", "deliveries"), - # A reference list's entries. Guarded: `lookup_service.delete_list` - # refuses while any remain, including a workspace's own additions to a - # platform list — so the cascade is the database agreeing with a rule - # already enforced above it, rather than the rule itself. ("LookupList", "items"), } diff --git a/tests/test_documents.py b/tests/test_documents.py index 9219ff2..82808f1 100644 --- a/tests/test_documents.py +++ b/tests/test_documents.py @@ -72,9 +72,6 @@ def _upload(client, headers, content=PNG, filename="picture.png", **form): ) -# --- what the file actually is ------------------------------------------- - - def test_the_declared_type_is_ignored(client, headers, db): """An HTML file announced as `image/png`. @@ -134,9 +131,6 @@ def test_an_executable_is_refused(client, headers): assert response.status_code == 400 -# --- the filename never becomes a path ----------------------------------- - - @pytest.mark.parametrize("filename", [ "../../../etc/passwd", "..\\..\\windows\\system32\\config", @@ -151,7 +145,6 @@ def test_a_filename_that_is_really_a_path_is_defused(client, headers, db, assert "/" not in stored and "\\" not in stored assert ".." not in stored - # And nothing was written outside the root. written = list(storage_root.rglob("*")) assert all(storage_root in path.parents or path == storage_root for path in written) @@ -185,9 +178,6 @@ def test_a_header_cannot_be_injected_through_the_filename(client, headers): assert "x-injected" not in {k.lower() for k in downloaded.headers} -# --- the download headers ------------------------------------------------- - - def test_a_download_forbids_sniffing_and_sandboxes_itself(client, headers): created = _upload(client, headers, PDF, "contract.pdf") downloaded = client.get( @@ -196,7 +186,6 @@ def test_a_download_forbids_sniffing_and_sandboxes_itself(client, headers): assert downloaded.headers["x-content-type-options"] == "nosniff" assert "sandbox" in downloaded.headers["content-security-policy"] - # A shared cache holding this would serve one customer's file to another. assert "no-store" in downloaded.headers["cache-control"] @@ -230,9 +219,6 @@ def test_the_bytes_come_back_unchanged(client, headers): assert downloaded.content == PDF -# --- size and quota ------------------------------------------------------- - - def test_a_file_over_the_limit_is_refused_and_leaves_nothing_behind( client, headers, db, storage_root, monkeypatch ): @@ -253,8 +239,6 @@ def test_a_file_over_the_limit_is_refused_and_leaves_nothing_behind( def test_a_workspace_over_quota_is_refused(client, headers, monkeypatch): - # Room for one file and not two, so the first is accepted and the second is - # the one that has to be refused. monkeypatch.setattr(settings, "DOCUMENT_QUOTA_BYTES", len(PDF) + 10) first = _upload(client, headers, PDF, "one.pdf") @@ -287,9 +271,6 @@ def test_a_deleted_document_stops_counting_against_the_quota(client, headers): assert after == 0 -# --- attaching ------------------------------------------------------------ - - def test_a_document_can_be_attached_and_found_again(client, headers): target = str(uuid.uuid4()) _upload(client, headers, PDF, "contract.pdf", @@ -323,9 +304,6 @@ def test_a_document_with_no_target_is_allowed(client, headers): assert _upload(client, headers, PDF, "handbook.pdf").status_code == 201 -# --- the workspace boundary ---------------------------------------------- - - def test_one_workspace_cannot_read_or_delete_another_s_document( client, headers, db, tenant_factory, plan_factory, role_factory, user_factory ): @@ -344,7 +322,6 @@ def test_one_workspace_cannot_read_or_delete_another_s_document( theirs = {"Authorization": f"Bearer {token}"} document_id = mine.json()["id"] - # 404 rather than 403: the id must not be a way to ask whether a file exists. assert client.get(f"/api/documents/{document_id}/content", headers=theirs).status_code == 404 assert client.delete(f"/api/documents/{document_id}", @@ -378,9 +355,6 @@ def test_the_routes_need_a_session(client): ).status_code in (401, 403) -# --- when the bytes have gone -------------------------------------------- - - def test_a_row_whose_file_vanished_answers_404_rather_than_crashing( client, headers, db ): @@ -401,9 +375,6 @@ def test_a_row_whose_file_vanished_answers_404_rather_than_crashing( assert downloaded.status_code == 404 -# --- the storage layer on its own ---------------------------------------- - - def test_a_key_that_escapes_the_root_is_refused(storage_root): with pytest.raises(ValueError): document_storage.write("../escape", io.BytesIO(b"x"), max_bytes=100) diff --git a/tests/test_email_addresses.py b/tests/test_email_addresses.py index a15dab9..9e45590 100644 --- a/tests/test_email_addresses.py +++ b/tests/test_email_addresses.py @@ -26,9 +26,6 @@ from app.services.auth.user_service import UserService from .conftest import requires_db -# ------------------------------------------------------------ normalisation - - @pytest.mark.parametrize( "written, canonical", [ @@ -61,9 +58,6 @@ def test_two_spellings_are_recognised_as_one_person(): assert not matches("alice@example.com", "alicia@example.com") -# -------------------------------------------------------------- on the way in - - @requires_db def test_an_account_is_stored_in_canonical_form(db, tenant_factory): tenant = tenant_factory() @@ -139,9 +133,6 @@ def test_changing_to_a_differently_cased_duplicate_is_refused(db, tenant_factory assert exc.value.status_code == 400 -# ------------------------------------------------------------ on the way back - - @requires_db def test_signing_in_works_whatever_case_was_typed(client, tenant_factory, user_factory): @@ -168,7 +159,6 @@ def test_a_row_written_before_this_still_signs_in(db, client, tenant_factory, user = user_factory(tenant=tenant, email="legacy@example.com") with unscoped(): - # Written the old way, bypassing the service. db.query(User).filter(User.id == user.id).update( {"email": "Legacy@Example.COM"} ) @@ -204,9 +194,6 @@ def test_a_password_reset_finds_the_account_whatever_was_typed(client, assert sent, "no reset code was sent for a differently-cased address" -# ------------------------------------------------------------- the guarantee - - @requires_db def test_the_database_refuses_a_duplicate_even_without_the_service(db, tenant_factory): diff --git a/tests/test_email_service.py b/tests/test_email_service.py index 5e4e0d5..5e782f1 100644 --- a/tests/test_email_service.py +++ b/tests/test_email_service.py @@ -43,8 +43,6 @@ def test_the_otp_path_uses_it(monkeypatch): assert EmailService.send_otp("person@example.com", "123456") is True assert seen["to"] == "person@example.com" assert "123456" in seen["body"] - # The expiry is part of the message on purpose: a code with no stated - # lifetime is one somebody tries an hour later and reports as broken. assert "10 minutes" in seen["body"] diff --git a/tests/test_entitlements.py b/tests/test_entitlements.py index 2488b22..d81a6a0 100644 --- a/tests/test_entitlements.py +++ b/tests/test_entitlements.py @@ -24,9 +24,6 @@ from .conftest import requires_db, utc_today pytestmark = requires_db -# --------------------------------------------------------------- the bound - - def test_a_plan_caps_the_role_rather_than_adding_to_it( db, tenant_factory, plan_factory, role_factory, user_factory ): @@ -85,9 +82,6 @@ def test_a_user_with_no_role_has_no_permissions_however_rich_the_plan( assert Entitlements.get_effective_access_codes(db, user) == set() -# ----------------------------------------------------------- subscription state - - @pytest.mark.parametrize( "overrides, live", [ @@ -136,9 +130,6 @@ def test_a_superadmin_is_not_bounded_by_a_plan(db, role_factory, user_factory): } -# ------------------------------------------------------------------- summary - - def test_the_subscription_summary_reports_liveness(db, tenant_factory, plan_factory): plan = plan_factory(max_users_allowed=5) live = tenant_factory(plan_id=plan.id) diff --git a/tests/test_env_example.py b/tests/test_env_example.py index 001fad0..4979584 100644 --- a/tests/test_env_example.py +++ b/tests/test_env_example.py @@ -45,16 +45,7 @@ def test_every_required_setting_is_in_the_template(template): assert not missing, f"required settings absent from .env.example: {missing}" -# Variables the running application genuinely does not read, because a one-off -# script reads them instead. Each is listed here rather than allowed by pattern, -# so adding one is a decision somebody made on purpose. -# -# They belong in the template even so: the operator who has to supply one finds -# it where they look for every other value, rather than in a script's docstring. SCRIPT_ONLY = { - # scripts/backfill_audit_tenants.py. An UPDATE against audit_logs, which is - # append-only for the application role — so it runs as the schema owner, by - # hand, once. The application must never hold this connection. "AUDIT_BACKFILL_DATABASE_URL", } @@ -103,9 +94,6 @@ def test_the_template_holds_no_real_secret(template): name, _, value = line.partition("=") if not any(word in name for word in ("SECRET", "PASSWORD", "KEY", "TOKEN")): continue - # Several settings carry one of those words and hold no credential: a - # bare number is a duration or a size, and an _ID names a key rather - # than being one. if value.isdigit() or name.endswith("_ID"): continue assert value == "" or "change-me" in value, ( diff --git a/tests/test_events.py b/tests/test_events.py index 08f555c..0663f63 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -82,9 +82,6 @@ def _logs(db, tenant=None): return query.order_by(EventLog.created_at).all() -# ------------------------------------------------------------------ emitting - - def test_an_event_becomes_an_outbox_row(db, module_target): """Written to the database in the caller's transaction, not posted inline. @@ -165,9 +162,6 @@ def test_provisioning_events_go_to_the_provisioning_endpoint(db, module_target): assert stream.endswith("/api/internal/events") -# ------------------------------------------------------------------ delivery - - @pytest.mark.parametrize("deliver", ["queue_item", "outbox"]) def test_a_delivered_event_is_signed_with_the_environment_secret( db, module_target, transport, deliver @@ -372,9 +366,6 @@ def test_a_network_error_is_retried_not_lost(db, module_target, monkeypatch): assert "connection reset" in row.error_log -# ---------------------------------------------------------------- follow-ups - - def test_a_follow_up_fires_only_after_the_first_event_lands( db, module_target, transport ): @@ -423,9 +414,6 @@ def test_a_follow_up_does_not_fire_when_the_first_event_fails( assert "ROLE_PROVISION_REQUESTED" not in types -# ----------------------------------------------------------------- the payload - - def test_the_payload_names_the_event_and_when_it_happened(db, module_target, transport): """Receivers deduplicate on `event_id`; without it, a retry that succeeded on the module's side but timed out on ours is applied twice.""" @@ -443,9 +431,6 @@ def test_the_payload_names_the_event_and_when_it_happened(db, module_target, tra assert body["data"]["tenant_id"] == str(module_target.tenant.id) -# --------------------------------------------------------------- what receivers see - - def test_the_delivery_headers_name_the_event_and_the_attempt(db, module_target, transport): """A receiver has to be able to dedupe before parsing, and to tell a diff --git a/tests/test_idempotency.py b/tests/test_idempotency.py index 0b28e98..f07dea4 100644 --- a/tests/test_idempotency.py +++ b/tests/test_idempotency.py @@ -52,9 +52,6 @@ def _create(client, headers, key=None, email="once@example.com"): "first_name": "A", "last_name": "B"}) -# --- the point of the whole thing ----------------------------------------- - - def test_the_same_key_does_the_work_once(client, headers, db, workspace): """The case it exists for: a client posts, the connection drops, and it has no way to know whether the work happened.""" @@ -99,9 +96,6 @@ def test_different_keys_are_different_requests(client, headers, db): "two@example.com").status_code == 201 -# --- the edges ------------------------------------------------------------ - - def test_a_key_reused_for_a_different_request_is_refused(client, headers): """The dangerous case. Answering it with the first response would have a client believe a request happened that never did — "charge £10" retried as @@ -142,7 +136,6 @@ def test_a_failed_request_does_not_hold_the_key(client, headers, db): one bad moment into a permanent one.""" key = str(uuid.uuid4()) - # A weak password is refused with a 400 — a real failure, from the endpoint. bad = client.post("/api/user/create", headers={**headers, "Idempotency-Key": key}, json={"email": "weak@example.com", "password": "password", @@ -154,7 +147,6 @@ def test_a_failed_request_does_not_hold_the_key(client, headers, db): IdempotencyRecord.idempotency_key == key ).count() == 0, "a failed response held the key" - # And the same key works for the corrected request. fixed = _create(client, headers, key, "weak@example.com") assert fixed.status_code == 201 @@ -191,9 +183,6 @@ def test_the_key_is_ignored_on_reads(client, headers): assert "Idempotent-Replay" not in first.headers -# --- scope ---------------------------------------------------------------- - - def test_two_workspaces_can_use_the_same_key(client, headers, db, tenant_factory, plan_factory, role_factory, user_factory): @@ -229,18 +218,13 @@ def test_the_same_key_on_a_different_endpoint_is_a_different_request( created = _create(client, headers, key, "moved@example.com") assert created.status_code == 201 - # A different endpoint with the same key must not replay the first response. listed = client.post("/api/user/create", headers={**headers, "Idempotency-Key": key}, json={"email": "moved@example.com", "password": PASSWORD, "first_name": "A", "last_name": "B"}) - # Same endpoint, same body: this one *is* a replay. assert listed.json() == created.json() -# --- housekeeping --------------------------------------------------------- - - def test_expired_keys_are_purged(db, workspace): with unscoped(): db.add(IdempotencyRecord( diff --git a/tests/test_identity_providers.py b/tests/test_identity_providers.py index c751315..e0084a0 100644 --- a/tests/test_identity_providers.py +++ b/tests/test_identity_providers.py @@ -104,10 +104,6 @@ def oidc(monkeypatch, signing_key): self.token_response = None def install(self): - # The SSRF guard resolves the host before any request is made, and - # `idp.example.com` does not exist. Stubbing the resolver is what - # lets the rest of the flow be exercised — the guard itself has its - # own tests in test_ssrf.py. monkeypatch.setattr( idp, "resolve_public_address", lambda host, port=None: "93.184.216.34" ) @@ -147,9 +143,6 @@ def _begin(db, provider) -> SsoLoginState: ) -# ------------------------------------------------------------------ starting - - def test_the_authorization_url_carries_everything_the_callback_will_check(db, provider): from urllib.parse import parse_qs, urlsplit @@ -161,10 +154,8 @@ def test_the_authorization_url_carries_everything_the_callback_will_check(db, assert query["response_type"] == ["code"] assert query["client_id"] == [CLIENT_ID] assert query["redirect_uri"] == [REDIRECT] - # PKCE: what makes an intercepted authorization code useless. assert query["code_challenge_method"] == ["S256"] assert query["code_challenge"] - # The two the callback verifies against what it stored. assert query["state"] and query["nonce"] @@ -202,9 +193,6 @@ def test_an_undiscovered_provider_cannot_be_used(db, provider): assert exc.value.status_code == 400 -# -------------------------------------------------------------- finishing - - def test_a_first_sign_in_provisions_an_account(db, provider, oidc, signing_key): state = _begin(db, provider) oidc.token_response = {"id_token": _id_token(signing_key, nonce=state.nonce)} @@ -215,7 +203,6 @@ def test_a_first_sign_in_provisions_an_account(db, provider, oidc, signing_key): assert user.email == "person@example.com" assert user.tenant_id == provider.tenant.id assert user.role_id == provider.role.id - # Linked on the provider's subject, which is what identifies the person. link = db.query(UserIdentity).filter(UserIdentity.user_id == user.id).one() assert link.subject == "ext-1" @@ -232,9 +219,6 @@ def test_a_provisioned_account_has_no_usable_password(db, provider, oidc, from app.config.security import security - # And a password attempt against such an account is a clean refusal rather - # than a 500: bcrypt raises on a value that is not a hash, which reached the - # sign-in path as an unhandled exception until this was found. assert not security.verify_password("", user.password) assert not security.verify_password("!", user.password) assert not security.verify_password("anything at all", user.password) @@ -263,7 +247,6 @@ def test_the_person_is_identified_by_subject_not_by_address(db, provider, oidc, with scoped_to(provider.tenant.id): first, _ = idp.complete_login(db, state.state, "c", REDIRECT) - # Same person at the provider, new address. state = _begin(db, provider) oidc.token_response = {"id_token": _id_token(signing_key, nonce=state.nonce, sub="ext-1", email="renamed@example.com")} @@ -273,9 +256,6 @@ def test_the_person_is_identified_by_subject_not_by_address(db, provider, oidc, assert again.id == first.id -# ---------------------------------------------------------- what is refused - - def test_a_state_that_was_never_issued_is_refused(db, provider, oidc, signing_key): """Without this, anyone can deliver a code to the callback and start a session as whoever that code belongs to.""" @@ -391,9 +371,6 @@ def test_a_response_with_no_token_is_refused(db, provider, oidc): assert exc.value.status_code == 502 -# ------------------------------------------------------------- who may enter - - def test_an_address_outside_the_allowed_domains_is_refused(db, provider, oidc, signing_key): """The difference between "our staff" and "anyone with a Google account".""" @@ -514,9 +491,6 @@ def test_provisioning_respects_the_seat_limit(db, provider, oidc, signing_key, assert exc.value.status_code in (402, 403, 409) -# --------------------------------------------------------------- the secret - - def test_the_client_secret_is_encrypted_at_rest(db, provider): assert provider.record.client_secret == "s3cret" assert "s3cret" not in (provider.record.client_secret_enc or "") @@ -533,9 +507,6 @@ def test_the_api_never_returns_the_client_secret(db, provider): assert "client_secret_set" in fields -# ------------------------------------------------------------------ sweeping - - def test_abandoned_logins_are_cleared(db, provider): """Somebody closes the tab on the consent screen and the row stays.""" state = _begin(db, provider) diff --git a/tests/test_invitations.py b/tests/test_invitations.py index 69dbdc8..2211d16 100644 --- a/tests/test_invitations.py +++ b/tests/test_invitations.py @@ -45,9 +45,6 @@ def workspace(db, tenant_factory, role_factory, access_factory): tenant=tenant, accesses=ADMIN_ACCESSES, ) - # The ids are captured as plain values. `roles` is under row-level - # security, so re-reading the object after a commit — with no tenant in - # context — makes SQLAlchemy think the row was deleted. return SimpleNamespace(tenant=tenant, role=role, role_id=role.id, tenant_id=tenant.id) @@ -73,9 +70,6 @@ def _invite(client, headers, email="newcomer@example.com", **extra): return response.json() -# --- the token ------------------------------------------------------------ - - def test_the_raw_token_is_never_stored(client, admin_headers, db): """A database dump must not be a set of working accounts. @@ -172,9 +166,6 @@ def test_resending_kills_the_previous_token(client, admin_headers, db): ).status_code == 201 -# --- what acceptance produces --------------------------------------------- - - def test_accepting_creates_an_account_the_admin_cannot_sign_into( client, admin_headers, db ): @@ -234,7 +225,6 @@ def test_a_weak_password_is_refused(client, admin_headers): response = client.post("/api/invitations/accept", json={"token": raw, "password": "password"}) assert response.status_code == 400 - # And the invitation is still usable, because nothing happened. assert client.post("/api/invitations/accept", json={"token": raw, "password": NEW_PASSWORD} ).status_code == 201 @@ -261,7 +251,6 @@ def test_case_does_not_create_a_second_person(client, admin_headers, db): User.email == "mixed.case@example.com" ).count() == 1 - # And signing in with any casing reaches it. assert client.post("/api/auth/signin", json={"email": "MIXED.CASE@example.com", "password": NEW_PASSWORD}).status_code == 200 @@ -286,9 +275,6 @@ def test_an_account_created_meanwhile_stops_the_acceptance( assert "sign in" in response.json()["detail"].lower() -# --- the workspace boundary ----------------------------------------------- - - def test_one_workspace_cannot_see_or_touch_another_s_invitations( client, db, admin_headers, workspace, tenant_factory, role_factory, access_factory, user_factory @@ -312,8 +298,6 @@ def test_one_workspace_cannot_see_or_touch_another_s_invitations( listed = client.get("/api/user/invitations", headers=theirs).json() assert all(item["email"] != "mine@example.com" for item in listed["items"]) - # 404, not 403: another workspace's id must be indistinguishable from one - # that never existed. assert client.delete( f"/api/user/invitations/{mine['invitation']['id']}", headers=theirs ).status_code == 404 @@ -349,9 +333,6 @@ def test_the_admin_routes_need_a_session(client): json={"email": "x@example.com"}).status_code in (401, 403) -# --- the preview ---------------------------------------------------------- - - def test_the_preview_says_who_it_is_for_and_nothing_more(client, admin_headers, workspace): created = _invite(client, admin_headers, email="peek@example.com") @@ -360,7 +341,6 @@ def test_the_preview_says_who_it_is_for_and_nothing_more(client, admin_headers, body = client.get("/api/invitations/preview", params={"token": raw}).json() assert body["email"] == "peek@example.com" assert body["workspace_name"] == workspace.tenant.tenant_name - # A guessed token must not become a way to read a workspace's staff list. assert set(body) == {"email", "workspace_name", "expires_at"} @@ -369,16 +349,12 @@ def test_the_preview_refuses_a_bad_token(client): params={"token": "nonsense"}).status_code == 404 -# --- seats ---------------------------------------------------------------- - - def test_an_invitation_cannot_be_sent_with_no_seat_left( client, db, admin_headers, workspace, plan_factory ): """Refused at send time so an administrator finds out immediately, rather than after the invitee has filled in a form.""" with unscoped(): - # One seat, and the administrator is already sitting in it. workspace.tenant.plan_id = plan_factory(max_users_allowed=1, accesses=ADMIN_ACCESSES).id db.commit() @@ -400,7 +376,6 @@ def test_the_seat_is_checked_again_at_acceptance( created = _invite(client, admin_headers, email="late@example.com") raw = created["acceptance_url"].split("token=")[1] - # The workspace fills up while the invitation sits in a mailbox. with unscoped(): workspace.tenant.plan_id = plan_factory(max_users_allowed=1, accesses=ADMIN_ACCESSES).id db.commit() @@ -413,9 +388,6 @@ def test_the_seat_is_checked_again_at_acceptance( assert db.query(User).filter(User.email == "late@example.com").count() == 0 -# --- housekeeping --------------------------------------------------------- - - def test_expired_invitations_are_purged_but_accepted_ones_are_kept( db, workspace, user_factory ): diff --git a/tests/test_mfa_and_lockout.py b/tests/test_mfa_and_lockout.py index e8e98ce..a4805bf 100644 --- a/tests/test_mfa_and_lockout.py +++ b/tests/test_mfa_and_lockout.py @@ -65,9 +65,6 @@ def enrolled(db, account): return account -# ------------------------------------------------------------------ enrolment - - def test_enrolment_returns_what_an_authenticator_needs(db, account): with scoped_to(account.tenant.id): started = mfa_service.begin_enrolment(db, account.user) @@ -130,9 +127,6 @@ def test_the_secret_is_encrypted_at_rest(db, enrolled): assert enrolled.secret not in (factor.secret_enc or "") -# ---------------------------------------------------------------- verifying - - def test_a_current_code_is_accepted(db, enrolled): with scoped_to(enrolled.tenant.id): assert mfa_service.verify(db, enrolled.user, _next_code(enrolled.secret)) is True @@ -157,7 +151,6 @@ def test_a_wrong_code_is_refused(db, enrolled): def test_a_code_from_the_previous_step_is_still_accepted(db, enrolled): """Phones drift and people type slowly. One step either side, not more — a wider window nearly doubles what an intercepted code is worth.""" - # A step behind *the one confirmation spent*, so still ahead of the counter. with scoped_to(enrolled.tenant.id): assert mfa_service.verify(db, enrolled.user, _next_code(enrolled.secret)) is True @@ -175,9 +168,6 @@ def test_an_account_with_no_factor_is_not_blocked(db, account): assert mfa_service.verify(db, account.user, "") is True -# ---------------------------------------------------------- recovery codes - - def test_recovery_codes_are_issued_once(db, enrolled): assert len(enrolled.recovery_codes) == mfa_service.RECOVERY_CODE_COUNT assert len(set(enrolled.recovery_codes)) == len(enrolled.recovery_codes) @@ -243,9 +233,6 @@ def test_disabling_removes_the_codes_too(db, enrolled): ).count() == 0 -# --------------------------------------------------------------- the lockout - - def test_a_wrong_password_counts_against_the_account(db, account): with scoped_to(account.tenant.id): lockout_service.record_failure(db, account.user) @@ -304,9 +291,6 @@ def test_an_administrator_can_unlock_early(db, account): assert lockout_service.is_locked(account.user) is False -# ------------------------------------------------------------ through sign-in - - def test_signing_in_needs_the_code_once_a_factor_exists(client, db, enrolled): with unscoped(): db.commit() @@ -316,8 +300,6 @@ def test_signing_in_needs_the_code_once_a_factor_exists(client, db, enrolled): json={"email": "factor@example.com", "password": "CorrectHorse!9"}, ) assert refused.status_code == 401 - # The one place the reason is given, because the client cannot know a factor - # is required until it is told. assert refused.headers.get("X-MFA-Required") == "true" accepted = client.post( @@ -408,7 +390,6 @@ def test_enough_failures_lock_the_account_however_they_arrive(db, tenant_factory lockout_service.record_failure(db, user) assert lockout_service.is_locked(user) is True - # And the right password no longer helps, which is the whole point. assert security.verify_password("CorrectHorse!9", user.password) is True @@ -460,12 +441,10 @@ def test_a_served_lockout_starts_the_count_again(db, tenant_factory, lockout_service.record_failure(db, user) assert lockout_service.is_locked(user) is True - # The lockout runs its course. user.locked_until = datetime.now(timezone.utc) - timedelta(seconds=1) db.flush() assert lockout_service.is_locked(user) is False - # One more mistake, well inside the decay window. lockout_service.record_failure(db, user) assert user.failed_login_attempts == 1, ( diff --git a/tests/test_mfa_routes.py b/tests/test_mfa_routes.py index 7624b18..c380571 100644 --- a/tests/test_mfa_routes.py +++ b/tests/test_mfa_routes.py @@ -96,7 +96,6 @@ def test_confirming_returns_recovery_codes_once(client, auth_headers, db): body = client.get("/api/auth/mfa", headers=auth_headers).json() assert body["enabled"] is True assert body["recovery_codes_remaining"] == mfa_service.RECOVERY_CODE_COUNT - # And there is no route that hands them back. assert "codes" not in body @@ -129,7 +128,6 @@ def test_disabling_needs_the_password_and_a_code(client, auth_headers, db, seeded_password): secret, _ = _enrol(client, auth_headers, db) - # Right password, no code: still refused. assert client.post( "/api/auth/mfa/disable", headers=auth_headers, @@ -171,7 +169,6 @@ def test_regenerating_replaces_the_whole_set(client, auth_headers, db, second = fresh.json()["codes"] assert not set(first) & set(second), "an old written-down code must stop working" - # And the old ones really are gone, not merely absent from the response. stale = client.post( "/api/auth/mfa/disable", headers=auth_headers, diff --git a/tests/test_module_contract.py b/tests/test_module_contract.py index 73fa9b0..e407ae9 100644 --- a/tests/test_module_contract.py +++ b/tests/test_module_contract.py @@ -27,9 +27,6 @@ def contract() -> str: return CONTRACT.read_text(encoding="utf-8") -# ---------------------------------------------------------------- the handoff - - def test_the_documented_handoff_version_is_the_one_that_ships(contract): from app.services.auth.sso_service import SIGNATURE_VERSION @@ -62,9 +59,6 @@ def test_every_field_the_handoff_signs_is_documented(contract): assert f'"{field}"' in contract, f"{field} is signed and not documented" -# --------------------------------------------------------------- the exchange - - def test_the_documented_inbound_headers_are_the_ones_read(contract): for header in ( "X-Module-Signature", @@ -105,9 +99,6 @@ def test_the_flag_name_in_the_document_exists(contract): assert hasattr(settings, "MODULE_TRUST_REQUIRE_REPLAY_CONTROLS") -# ----------------------------------------------------------------- the events - - def test_every_event_header_is_documented(contract): for header in ( "X-SaaS-Signature", @@ -139,17 +130,11 @@ def test_the_documented_event_paths_are_the_real_ones(contract): assert "provisioning_endpoint" in contract -# ------------------------------------------------------------ permission sync - - def test_the_documented_sync_response_shape_is_the_one_parsed(contract): import inspect from app.services.auth import module_permission_service - # The whole module: parent linking lives in its own helper, and a check that - # only read `sync_permissions` would report a field as undocumented purely - # because it was refactored out. source = inspect.getsource(module_permission_service) for field in ("permission_code", "parent_code", "category", "hash"): assert field in source, f"{field} is documented and not read" @@ -162,9 +147,6 @@ def test_the_document_says_stale_permissions_are_reported_not_deleted(contract): assert "`stale`" in contract -# ------------------------------------------------------------------ the shape - - def test_the_checklist_covers_every_section(contract): """A contract with a checklist that has fallen behind the body is a contract people implement from the checklist and get wrong.""" @@ -180,9 +162,6 @@ def test_the_checklist_covers_every_section(contract): assert topic in body, f"{topic} is in the checklist but not explained above" -# --------------------------------------------------------------- key identity - - def test_the_document_says_where_to_fetch_the_public_key(contract): """A `kid` header with nowhere to look it up is decoration.""" assert "/.well-known/jwks.json" in contract diff --git a/tests/test_module_identity.py b/tests/test_module_identity.py index de8fa48..fe4852a 100644 --- a/tests/test_module_identity.py +++ b/tests/test_module_identity.py @@ -45,9 +45,6 @@ def unconfigured(monkeypatch): module_identity._public_numbers.cache_clear() -# --------------------------------------------------------------------- jwks - - def test_the_published_key_verifies_a_real_token(signing_key): """The end-to-end claim: what the platform signs, the document verifies. @@ -64,7 +61,6 @@ def test_the_published_key_verifies_a_real_token(signing_key): document = module_identity.jwks() key = document["keys"][0] - # Rebuilt from the published n and e, exactly as a receiver would. from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers def _int(value: str) -> int: @@ -98,7 +94,6 @@ def test_the_document_is_the_shape_receivers_expect(signing_key): assert key["kty"] == "RSA" assert key["alg"] == "RS256" assert key["use"] == "sig" - # base64url, unpadded — a padded value is rejected by strict parsers. assert "=" not in key["n"] and "=" not in key["e"] assert "+" not in key["n"] and "/" not in key["n"] @@ -141,9 +136,6 @@ def test_a_malformed_key_does_not_become_a_500(monkeypatch, caplog): module_identity._public_numbers.cache_clear() -# ---------------------------------------------------------------- the route - - @requires_db def test_the_document_is_served_without_a_credential(client, signing_key): """Deliberately unauthenticated: a module needs this before it can trust @@ -153,7 +145,6 @@ def test_the_document_is_served_without_a_credential(client, signing_key): assert response.status_code == 200 assert len(response.json()["keys"]) == 1 - # Keys change on a deployment; a module should not re-fetch per token. assert "max-age" in response.headers.get("cache-control", "") @@ -177,9 +168,6 @@ def test_the_health_check_reports_a_configured_key(client, signing_key): assert body["module_identity"] == "configured" -# ------------------------------------------------------------ the exchange - - @requires_db def test_an_exchange_without_a_key_refuses_clearly(db, unconfigured, fake_redis, tenant_factory, plan_factory, diff --git a/tests/test_module_registry.py b/tests/test_module_registry.py index 8634904..a18083f 100644 --- a/tests/test_module_registry.py +++ b/tests/test_module_registry.py @@ -39,9 +39,6 @@ def _env_data(slug="staging", **overrides): ) -# ------------------------------------------------------------- environments - - def test_a_secret_is_encrypted_the_moment_it_is_stored(db, module_factory): """Through the accessor, never the column. A plaintext secret in the database is a plaintext secret in every backup taken since.""" @@ -177,9 +174,6 @@ def test_the_provisioning_endpoint_is_not_silently_dropped(db, module_factory): assert env.provisioning_endpoint == "/custom/provision" -# --------------------------------------------------------- permission sync - - @pytest.fixture def sync(monkeypatch): """Stands in for the module's permission endpoint.""" @@ -309,7 +303,6 @@ def test_a_parent_cycle_is_broken(db, module_factory, environment_factory, sync) for a in db.query(ModuleAccess).filter(ModuleAccess.module_id == module.id) } - # Whichever way round it resolves, following parents must terminate. seen = set() node = rows["a"] while node is not None and node.access_code not in seen: @@ -403,9 +396,6 @@ def test_a_malformed_module_id_is_a_400(db, sync): assert exc.value.status_code == 400 -# ------------------------------------------------------ who may trigger a sync - - def test_a_tenantless_account_is_not_a_superadmin(client, db, user_factory, sync, module_factory, environment_factory): diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 37e3335..c87f0cc 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -61,9 +61,6 @@ def _notify(db, person, workspace, **extra): return notification_service.notify(db, **payload) -# --- the basics ----------------------------------------------------------- - - def test_a_notice_reaches_its_person(client, headers, db, person, workspace): with unscoped(): _notify(db, person, workspace, title="Your webhook stopped") @@ -134,9 +131,6 @@ def test_unread_only_filters(client, headers, db, person, workspace): assert len(filtered["items"]) == 1 -# --- addressing and the boundary ------------------------------------------ - - def test_nobody_can_read_or_touch_another_person_s_notices( client, headers, db, person, workspace, user_factory ): @@ -202,9 +196,6 @@ def test_administrators_are_found_by_permission_not_by_title( assert unable.id not in recipients -# --- deduplication -------------------------------------------------------- - - def test_the_same_thing_going_wrong_repeatedly_is_one_notice(db, person, workspace): """Twenty webhook failures in an hour is one thing that has gone wrong. @@ -250,9 +241,6 @@ def test_a_read_notice_does_not_suppress_the_next_one(db, person, workspace): assert count == 2 -# --- what actually raises one --------------------------------------------- - - def test_a_disabled_webhook_tells_somebody(db, workspace, person, monkeypatch): """The reason this whole feature exists. Until now an integration stopping was a line in a log file.""" @@ -347,9 +335,6 @@ def test_an_accepted_invitation_tells_whoever_sent_it(client, headers, db, assert "joining@example.com" in (notices[0].body or "") -# --- housekeeping --------------------------------------------------------- - - def test_old_read_notices_are_purged_and_unread_ones_are_not(db, person, workspace): """Deleting something nobody has seen is deciding on their behalf that it did @@ -376,9 +361,6 @@ def test_old_read_notices_are_purged_and_unread_ones_are_not(db, person, assert remaining == {"Old and unseen"} -# --- preferences ---------------------------------------------------------- - - def test_every_kind_comes_back_with_an_answer(client, headers): """Filled in from the catalogue, not from stored rows. A row exists only where somebody has turned something off, so a raw listing would be empty for @@ -483,7 +465,6 @@ def test_a_preference_is_the_caller_s_own(client, headers, db, workspace, client.put("/api/notifications/preferences", headers=headers, json={"kind": notification_service.MFA_ENABLED, "enabled": False}) - # The other person is unaffected. with unscoped(): assert notification_service.wants( db, other.id, notification_service.MFA_ENABLED diff --git a/tests/test_operations.py b/tests/test_operations.py index 4441961..687409a 100644 --- a/tests/test_operations.py +++ b/tests/test_operations.py @@ -45,9 +45,6 @@ def member_token(client, tenant_factory, user_factory): return {"Authorization": f"Bearer {token}"} -# --------------------------------------------------------------- who may look - - @pytest.mark.parametrize( "path", ["subscription-notices", "outbox", "sessions"], @@ -70,9 +67,6 @@ def test_a_superadmin_may(client, superadmin_token, path): ).status_code == 200 -# ------------------------------------------------------- subscription notices - - def test_notices_are_summarised_by_kind(client, db, superadmin_token, tenant_factory, plan_factory): from app.services.auth import subscription_notices @@ -146,9 +140,6 @@ def test_a_workspace_with_no_end_date_is_not_counted_as_unreachable( assert after == before -# --------------------------------------------------------------------- outbox - - def test_a_module_that_keeps_refusing_shows_up(client, db, superadmin_token, tenant_factory, module_factory, environment_factory, @@ -184,7 +175,6 @@ def test_a_module_that_keeps_refusing_shows_up(client, db, superadmin_token, row = db.query(EventLog).order_by(EventLog.created_at.desc()).first() EventService.process_queue_item(db, str(row.event_id)) - # Due again, and already tried once. row.next_retry_at = datetime.now(timezone.utc) - timedelta(minutes=1) db.commit() @@ -204,9 +194,6 @@ def test_a_healthy_outbox_reports_nothing_stuck(client, db, superadmin_token): assert body["failing_targets"] == [] -# ------------------------------------------------------------------- sessions - - def test_a_detected_token_reuse_is_surfaced(client, db, superadmin_token, tenant_factory, user_factory): """A security signal, not a capacity one. diff --git a/tests/test_org_units.py b/tests/test_org_units.py index 4199153..d7b96ea 100644 --- a/tests/test_org_units.py +++ b/tests/test_org_units.py @@ -60,9 +60,6 @@ def _person(client, headers, email): return response.json() -# --- the tree ------------------------------------------------------------- - - def test_a_unit_knows_where_it_sits(client, headers): root = _unit(client, headers, "Pakistan") branch = _unit(client, headers, "Lahore", root["id"]) @@ -160,9 +157,6 @@ def test_an_empty_leaf_can_be_deleted(client, headers): headers=headers).status_code == 204 -# --- membership ----------------------------------------------------------- - - def test_somebody_can_be_in_several_units_with_one_primary(client, headers, db): """A person split across two branches is ordinary, but exactly one unit is the one shown beside their name.""" @@ -210,9 +204,6 @@ def test_removing_a_member_leaves_the_person(client, headers, db): headers=headers).status_code == 200 -# --- scoping, and only ever narrowing ------------------------------------- - - def test_no_scopes_means_the_whole_workspace(client, headers, db, workspace, user_factory): """The default, and what every administrator is today. A rule that could @@ -242,7 +233,6 @@ def test_a_scoped_administrator_sees_only_their_branch( client.post(f"/api/org-units/{lahore['id']}/administrators", headers=headers, json={"user_id": manager["id"]}) - # Give the manager a role that can manage users at all. client.put(f"/api/user/update/{manager['id']}", headers=headers, json={"role_id": str(workspace.role_id)}) @@ -254,8 +244,6 @@ def test_a_scoped_administrator_sees_only_their_branch( seen = {u["email"] for u in client.get("/api/user/get", headers=theirs).json()} assert "lahore.staff@example.com" in seen assert "karachi.staff@example.com" not in seen - # Always themselves: an administrator who cannot see their own account - # cannot change their own password through the same screens. assert "lahore.manager@example.com" in seen @@ -328,9 +316,6 @@ def test_a_scope_grants_nothing_on_its_own(client, headers, db, workspace, email="powerless@example.com") with unscoped(): db.commit() - # Captured inside the bypass: `users` is under row-level security, so - # re-reading the instance after the commit with no tenant in context - # makes SQLAlchemy report the row as deleted. person_id = str(person.id) client.post(f"/api/org-units/{unit['id']}/members", headers=headers, @@ -401,9 +386,6 @@ def test_removing_the_last_scope_widens_again(client, headers, db, workspace): assert "elsewhere@example.com" in after -# --- the workspace boundary ----------------------------------------------- - - def test_one_workspace_cannot_see_or_touch_another_s_units( client, headers, db, tenant_factory, plan_factory, role_factory, user_factory ): @@ -436,9 +418,6 @@ def test_the_routes_need_a_session(client): json={"name": "x"}).status_code in (401, 403) -# --- seats allocated to a branch ------------------------------------------ - - def _allocate(client, headers, unit_id, limit): return client.put(f"/api/org-units/{unit_id}/seats", headers=headers, json={"seat_limit": limit}) @@ -621,8 +600,6 @@ def test_the_listing_carries_each_unit_s_seats(client, headers, db, workspace, assert listing["Lahore"]["seat_limit"] == 4 assert listing["Lahore"]["seats_used"] == 1 - # Absent rather than zero: no allocation means unconstrained, and a screen - # showing "0" there would read as "nobody may join". assert listing["Karachi"]["seat_limit"] is None assert listing["Karachi"]["seats_used"] == 0 diff --git a/tests/test_plans_and_roles.py b/tests/test_plans_and_roles.py index 26f26e6..9b6a73f 100644 --- a/tests/test_plans_and_roles.py +++ b/tests/test_plans_and_roles.py @@ -37,9 +37,6 @@ def _plan(name: str | None = None, **overrides): ) -# ------------------------------------------------------------------ plan basics - - def test_a_plan_name_is_unique(db): with unscoped(): SubscriptionPlanService.create_plan(db, _plan("Enterprise Tier")) @@ -96,9 +93,6 @@ def test_the_permissions_a_plan_allows_can_be_replaced(db, access_factory): assert {pa.access_id for pa in refreshed.plan_accesses} == {read.id} -# ---------------------------------------------------------------- grace window - - def test_a_grace_period_can_be_configured(db): """The column exists and the lifecycle honours it. Without this it could not be set through the API at all — a feature with no way in.""" @@ -147,9 +141,6 @@ def test_the_configured_grace_reaches_the_lifecycle(db, tenant_factory): assert lifecycle.grace_until == utc_today() + timedelta(days=7) -# -------------------------------------------------------------- deleting a plan - - def test_a_plan_in_use_cannot_be_deleted(db, tenant_factory): """It used to try, and the foreign key stopped it — as a 500 with a database error in the body. The workspaces on the plan are the answer the caller @@ -203,9 +194,6 @@ def test_deleting_a_plan_does_not_erase_the_history_of_it(db, tenant_factory): assert entries[0].change_type -# ------------------------------------------------------------------ role basics - - def test_a_role_name_is_unique_within_a_workspace(db, tenant_factory): tenant = tenant_factory() with unscoped(): @@ -281,9 +269,6 @@ def test_platform_and_module_permissions_are_sorted_apart(db, tenant_factory, } == {module_permission.id} -# ------------------------------------------------------------ deleting a role - - def test_a_role_still_held_by_someone_cannot_be_deleted(db, tenant_factory, role_factory, user_factory): """It used to try, and the foreign key stopped it — as a 500. diff --git a/tests/test_query_counts.py b/tests/test_query_counts.py index a25012c..cfadd01 100644 --- a/tests/test_query_counts.py +++ b/tests/test_query_counts.py @@ -64,8 +64,6 @@ def workspace_with(db, tenant_factory, plan_factory, access_factory): ) ) db.flush() - # Otherwise everything just written is already in memory and no - # query would be needed to read it back. db.expire_all() return tenant @@ -80,16 +78,11 @@ def _constant(counter, workspace_with, read): tenant = workspace_with(size) with scoped_to(tenant.id), counter() as counted: consumed = read(tenant) - # Force the serialisation the response would do. A count taken - # before the relationships are touched measures nothing. assert consumed is not None counts.append(counted["n"]) return counts -# ------------------------------------------------------------------- users - - def test_listing_users_does_not_query_per_user(db, query_counter, workspace_with): """Was 8 queries for 5 users and 28 for 25 — the role and the workspace name were both lazy, and the response renders both.""" @@ -129,9 +122,6 @@ def test_the_paginated_user_list_does_not_query_per_user(db, query_counter, assert small == larger -# ------------------------------------------------------------------- roles - - def test_listing_roles_does_not_query_per_role(db, query_counter, workspace_with): """Was 7 for 5 roles and 27 for 25. Each role's permission grants were lazy, and the response renders them.""" @@ -150,9 +140,6 @@ def test_listing_roles_does_not_query_per_role(db, query_counter, workspace_with ) -# --------------------------------------------------------------- workspaces - - def test_listing_workspaces_does_not_query_per_workspace(db, query_counter, tenant_factory, plan_factory): @@ -179,9 +166,6 @@ def test_listing_workspaces_does_not_query_per_workspace(db, query_counter, ) -# ------------------------------------------------------------------ the rule - - def test_the_counter_would_actually_notice(db, query_counter, workspace_with): """A check that cannot fail is worse than no check. @@ -206,12 +190,6 @@ def test_the_counter_would_actually_notice(db, query_counter, workspace_with): ) -# --------------------------------------------------- the paths I assumed were flat -# -# "Flatter" was exactly the reasoning that made the workspace list look fine in a -# first probe, so these are measured rather than reasoned about. - - def test_listing_plans_does_not_query_per_plan(db, query_counter, plan_factory, access_factory): """Each plan carries its own permission grants, so a lazy read here would be diff --git a/tests/test_reference_data.py b/tests/test_reference_data.py index a698077..b0cbab8 100644 --- a/tests/test_reference_data.py +++ b/tests/test_reference_data.py @@ -69,9 +69,6 @@ def closed_list(db): return record -# --- reading -------------------------------------------------------------- - - def test_a_platform_list_is_visible_to_a_workspace(client, headers, platform_list): listed = client.get("/api/reference", headers=headers).json() codes = {row["code"] for row in listed} @@ -110,9 +107,6 @@ def test_the_routes_need_a_session(client): assert client.get("/api/reference").status_code in (401, 403) -# --- a workspace extending a platform list ------------------------------- - - def test_a_workspace_can_add_its_own_item_to_a_platform_list(client, headers, platform_list): """The ordinary case. Without it, "the standard types plus the two we use" @@ -160,7 +154,6 @@ def test_one_workspace_never_sees_another_s_additions(client, headers, db, headers={"Authorization": f"Bearer {token}"}).json() codes = {row["code"] for row in items} assert "ours" not in codes - # And they still see the platform's own. assert {"invoice", "contract"} <= codes @@ -186,9 +179,6 @@ def test_a_workspace_cannot_edit_a_platform_list(client, headers, platform_list) assert refused.status_code == 403 -# --- a workspace's own lists --------------------------------------------- - - def test_a_workspace_can_keep_its_own_list(client, headers): created = client.post("/api/reference", headers=headers, json={"code": "cost-centre", "name": "Cost centres"}) @@ -245,9 +235,6 @@ def test_both_lists_are_still_listed(client, headers, platform_list): assert {row["is_platform"] for row in matching} == {True, False} -# --- editing and retiring ------------------------------------------------- - - def test_a_code_cannot_be_renamed(client, headers): """A code is what integrations name and stored records point at. Renaming one is a silent data migration disguised as an edit — so the update schema @@ -276,11 +263,9 @@ def test_an_item_retires_rather_than_disappearing(client, headers): assert retired.status_code == 200 assert retired.json()["is_active"] is False - # Gone from the picker... active = client.get("/api/reference/status/items", headers=headers).json() assert active == [] - # ...and still resolvable for the management screen. everything = client.get("/api/reference/status/items", headers=headers, params={"include_inactive": True}).json() assert [row["code"] for row in everything] == ["pending"] @@ -347,9 +332,6 @@ def test_managing_needs_the_permission(client, db, tenant_factory, plan_factory, json={"code": "x", "name": "X"}).status_code == 403 -# --- seeding -------------------------------------------------------------- - - def test_seeding_is_idempotent(db): """It runs on deploy, so running it twice must add nothing.""" unique = uuid.uuid4().hex[:8] @@ -405,9 +387,6 @@ def test_the_cascade_is_never_the_thing_that_protects_the_items(db, workspace): from app.core.tenant_context import scoped_to - # Scoped explicitly: the service is called directly here rather than through - # a request, so nothing has set a workspace — and the row-level security - # policy refuses a write with none, which is exactly what it is for. with scoped_to(workspace.tenant_id): record = lookup_service.create_list( db, tenant_id=workspace.tenant_id, code="protected", name="Protected", @@ -421,8 +400,6 @@ def test_the_cascade_is_never_the_thing_that_protects_the_items(db, workspace): lookup_service.delete_list(db, record) assert refused.value.status_code == 409 - # And the item is still there, rather than having gone with a half-done - # delete. assert db.query(LookupItem).filter( LookupItem.list_id == record.id ).count() == 1 diff --git a/tests/test_rls.py b/tests/test_rls.py index b147ace..cd24815 100644 --- a/tests/test_rls.py +++ b/tests/test_rls.py @@ -93,9 +93,6 @@ def _scoped(conn, tenant_id): ) -# ------------------------------------------------------------------ the policy - - def test_the_application_role_cannot_bypass(app_engine): """If it could, every test below would pass while proving nothing.""" with app_engine.connect() as conn: @@ -177,9 +174,6 @@ def test_bypass_sees_across_workspaces(app_engine, seeded): assert count == 2 -# ------------------------------------------------------------------- writes - - def test_a_workspace_cannot_write_into_another(app_engine, seeded): """USING controls reads; WITH CHECK controls writes. Both are needed. @@ -222,7 +216,6 @@ def test_a_workspace_cannot_move_a_user_into_another(app_engine, seeded, engine) {"b": bravo, "e": f"alpha-{suffix}@example.com"}, ) - # And the row is still where it started. with engine.begin() as conn: conn.execute(text("SELECT set_config('app.bypass_rls', 'on', true)")) owner = conn.execute( @@ -252,9 +245,6 @@ def test_a_workspace_cannot_create_a_shared_role(app_engine, seeded): ) -# ------------------------------------------------------------------ reporting - - def test_the_health_check_agrees_isolation_is_in_force(app_engine): from sqlalchemy.orm import sessionmaker diff --git a/tests/test_scim.py b/tests/test_scim.py index fee53ee..3a23490 100644 --- a/tests/test_scim.py +++ b/tests/test_scim.py @@ -55,9 +55,6 @@ def _create(client, headers, username="newhire@example.com", **extra): }) -# --- discovery ------------------------------------------------------------ - - def test_the_three_discovery_documents_exist(client, headers): """Azure AD fetches all three before doing anything, and reports "endpoint not compliant" rather than naming the one it could not find.""" @@ -82,9 +79,6 @@ def test_scim_needs_a_credential(client): assert client.get(path).status_code in (401, 403), path -# --- provisioning --------------------------------------------------------- - - def test_a_directory_can_create_an_account(client, headers, db, workspace): response = _create(client, headers) assert response.status_code == 201, response.text @@ -123,7 +117,6 @@ def test_a_duplicate_is_a_uniqueness_conflict(client, headers): body = clash.json() assert body["scimType"] == "uniqueness" assert body["schemas"] == [scim.ERROR_SCHEMA] - # Not wrapped in FastAPI's {"detail": ...}, which no directory parses. assert "detail" in body and isinstance(body["detail"], str) @@ -163,9 +156,6 @@ def test_provisioning_respects_the_seat_limit(client, headers, db, workspace, assert response.json()["schemas"] == [scim.ERROR_SCHEMA] -# --- deprovisioning, the part that matters -------------------------------- - - def test_okta_style_deactivation_works(client, headers, db): """Okta sends the path form.""" created = _create(client, headers, "leaver1@example.com").json() @@ -215,14 +205,9 @@ def test_the_string_false_is_not_treated_as_true(client, headers, db): def test_a_deactivated_person_cannot_sign_in(client, headers, db, workspace, user_factory): - # No role: signing in does not need one, and reading `workspace.role` here - # would re-load a row-level-secured row with no tenant in context. real = user_factory(tenant=workspace.tenant, email="realleaver@example.com") with unscoped(): db.commit() - # Captured as a plain value: `users` is under row-level security, so - # re-reading the instance later with no tenant in context makes - # SQLAlchemy think the row was deleted. real_id = real.id assert client.post("/api/auth/signin", @@ -272,15 +257,12 @@ def test_reactivating_respects_the_seat_limit(client, headers, db, workspace, assert back.status_code == 409 -# --- listing and filtering ------------------------------------------------ - - def test_the_list_shape_is_what_a_client_expects(client, headers): _create(client, headers, "listed@example.com") body = client.get("/scim/v2/Users", headers=headers).json() assert body["schemas"] == [scim.LIST_SCHEMA] - assert body["startIndex"] == 1 # 1-based, per the specification + assert body["startIndex"] == 1 assert body["totalResults"] >= 1 assert len(body["Resources"]) == body["itemsPerPage"] @@ -337,9 +319,6 @@ def test_paging_does_not_skip_anybody(client, headers): assert len(seen) == min(6, total) -# --- the workspace boundary ----------------------------------------------- - - def test_one_workspace_cannot_see_or_touch_another_s_people( client, headers, db, workspace, tenant_factory, plan_factory, role_factory, user_factory @@ -391,9 +370,6 @@ def test_revoking_the_key_stops_the_sync(client, headers): assert client.get("/scim/v2/Users", headers=as_key).status_code == 401 -# --- groups --------------------------------------------------------------- - - def test_groups_are_roles(client, headers, workspace): body = client.get("/scim/v2/Groups", headers=headers).json() names = {g["displayName"] for g in body["Resources"]} diff --git a/tests/test_seats.py b/tests/test_seats.py index 9c516ac..65c819a 100644 --- a/tests/test_seats.py +++ b/tests/test_seats.py @@ -33,9 +33,6 @@ def _new_user(**overrides) -> UserCreate: ) -# --------------------------------------------------------------------- counting - - def test_only_active_users_consume_a_seat(db, tenant_factory, plan_factory, user_factory): """A disabled account cannot sign in, so charging a seat for it would be wrong.""" plan = plan_factory(max_users_allowed=5) @@ -85,18 +82,10 @@ def test_a_superadmin_consumes_no_seat(db): assert usage.unlimited is True -# ------------------------------------------------------------------ enforcement - - def test_creating_a_user_within_the_limit_succeeds(db, tenant_factory, plan_factory): plan = plan_factory(max_users_allowed=2) tenant = tenant_factory(plan_id=plan.id) - # Read inside the scope. An ORM instance expires after a flush and reloads - # lazily, and that reload is subject to the policy too — touching an - # attribute outside the workspace raises ObjectDeletedError rather than - # returning stale data, which is the right failure but a surprising one the - # first time. with scoped_to(tenant.id): created = UserService.create_user(db, _new_user(), tenant.id) assert created.id is not None @@ -116,7 +105,6 @@ def test_creating_a_user_beyond_the_limit_is_refused( UserService.create_user(db, _new_user(), tenant.id) assert exc.value.status_code == 403 - # The message has to say what to do about it, not just that it failed. assert "seats" in exc.value.detail assert "upgrade" in exc.value.detail.lower() @@ -167,7 +155,7 @@ def test_reactivating_a_user_is_refused_when_the_workspace_is_full( tenant = tenant_factory(plan_id=plan.id) user_factory(tenant=tenant) dormant = user_factory(tenant=tenant, status="disabled") - user_factory(tenant=tenant) # took the freed seat + user_factory(tenant=tenant) with pytest.raises(HTTPException) as exc: with scoped_to(tenant.id): @@ -187,9 +175,6 @@ def test_an_unlimited_workspace_is_never_refused(db, tenant_factory, plan_factor assert SeatService.usage(db, tenant.id).used == 5 -# ------------------------------------------------------- pre-existing overages - - def test_a_workspace_already_over_its_limit_is_reported_and_blocked( db, tenant_factory, plan_factory, user_factory ): @@ -214,9 +199,6 @@ def test_a_workspace_already_over_its_limit_is_reported_and_blocked( UserService.create_user(db, _new_user(), tenant.id) -# --------------------------------------------------- visible before the wall - - def test_the_subscription_summary_reports_seat_usage( db, tenant_factory, plan_factory, user_factory ): diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 701a1f0..9d2fe9c 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -31,9 +31,6 @@ def account(db, tenant_factory, role_factory, user_factory): return SimpleNamespace(tenant=tenant, role=role, user=user) -# ------------------------------------------------------------------ the record - - def test_signing_in_records_where_from(db, account): """A list of opaque identifiers is not reviewable. "Chrome on Windows, from this address, two minutes ago" is.""" @@ -68,9 +65,6 @@ def test_an_overlong_user_agent_is_truncated_not_refused(db, account): assert len(session.user_agent) == 512 -# --------------------------------------------------------------------- rotation - - def test_refreshing_moves_the_session_to_a_new_token(db, account): with unscoped(): session = session_service.start(db, account.user) @@ -89,7 +83,6 @@ def test_the_new_token_works_and_the_old_one_does_not(db, account): spent = session.current_jti rotated = session_service.rotate(db, spent) - # The successor refreshes normally. session_service.rotate(db, rotated.current_jti) @@ -166,9 +159,6 @@ def test_a_token_with_a_nonsense_jti_is_refused(db, account): session_service.rotate(db, "not-a-uuid") -# ------------------------------------------------------------------- revoking - - def test_revoking_is_idempotent_and_keeps_the_first_reason(db, account): """Signing out after a detected theft must not rewrite the record as a routine sign-out.""" @@ -221,9 +211,6 @@ def test_one_account_cannot_end_another_account_s_sessions(db, account, assert session_service.active_for(db, other.id) == [] -# ------------------------------------------------------------------- listing - - def test_only_live_sessions_are_listed(db, account): with unscoped(): live = session_service.start(db, account.user) @@ -276,9 +263,6 @@ def test_long_dead_sessions_are_eventually_cleared(db, account): assert db.query(UserSession).filter(UserSession.id == session.id).count() == 0 -# ---------------------------------------------------- through the HTTP surface - - def test_signing_in_and_out_opens_and_closes_a_session(client, db, tenant_factory, user_factory): tenant = tenant_factory() @@ -298,8 +282,6 @@ def test_signing_in_and_out_opens_and_closes_a_session(client, db, tenant_factor listed = client.get("/api/auth/sessions", headers=auth) assert listed.status_code == 200 assert len(listed.json()) == 1 - # The cookie went back with the sign-in response, so the client is holding - # this session and the list has to say so. assert listed.json()[0]["is_current"] is True assert client.post("/api/auth/logout", headers=auth).status_code == 200 @@ -316,7 +298,6 @@ def test_signing_out_everywhere_leaves_the_current_session_alone( user_id = user.id credentials = {"email": "everywhere@example.com", "password": "CorrectHorse!9"} - # Two other places, then the one doing the asking. client.post("/api/auth/signin", json=credentials) client.post("/api/auth/signin", json=credentials) current = client.post("/api/auth/signin", json=credentials) diff --git a/tests/test_soft_delete.py b/tests/test_soft_delete.py index 95e1fe8..d887c96 100644 --- a/tests/test_soft_delete.py +++ b/tests/test_soft_delete.py @@ -48,9 +48,6 @@ def _add(client, headers, email="doomed@example.com"): return response.json() -# --- what deletion does --------------------------------------------------- - - def test_the_row_survives_and_remembers_who_it_was(client, headers, db): created = _add(client, headers) assert client.delete(f"/api/user/delete/{created['id']}", @@ -108,7 +105,6 @@ def test_the_address_is_released(client, headers, db): def test_a_deleted_account_frees_its_seat(client, headers, db, workspace, plan_factory): with unscoped(): - # Two seats: the administrator, and one more. workspace.tenant.plan_id = plan_factory(max_users_allowed=2, accesses=ACCESSES).id db.commit() @@ -140,9 +136,6 @@ def test_scim_does_not_see_a_deleted_account(client, headers, db): headers=headers).status_code == 404 -# --- restoring ------------------------------------------------------------ - - def test_a_deletion_can_be_undone(client, headers, db): created = _add(client, headers, "mistake@example.com") client.delete(f"/api/user/delete/{created['id']}", headers=headers) @@ -234,8 +227,6 @@ def test_deleting_twice_is_harmless(client, headers, db): created = _add(client, headers, "twice@example.com") assert client.delete(f"/api/user/delete/{created['id']}", headers=headers).status_code == 200 - # Already gone from every lookup, so the second attempt is a 404 rather than - # a second deletion overwriting the recorded address with the tombstone. assert client.delete(f"/api/user/delete/{created['id']}", headers=headers).status_code == 404 @@ -245,9 +236,6 @@ def test_deleting_twice_is_harmless(client, headers, db): assert row.deleted_email == "twice@example.com" -# --- finding what was deleted --------------------------------------------- - - def test_deleted_accounts_can_be_found_again(client, headers, db): """Soft delete is only useful if you can find what was deleted. Without this list, restoring means already knowing an id that no screen shows.""" diff --git a/tests/test_soft_delete_scope.py b/tests/test_soft_delete_scope.py index 9adf347..a8265dc 100644 --- a/tests/test_soft_delete_scope.py +++ b/tests/test_soft_delete_scope.py @@ -37,9 +37,6 @@ def workspace(db, tenant_factory, plan_factory, role_factory): return SimpleNamespace(tenant=tenant, tenant_id=tenant.id, role=role) -# --- workspaces ----------------------------------------------------------- - - def test_deleting_a_workspace_keeps_the_row(db, tenant_factory): """Every audit entry, subscription record and history row points at it. Removing it makes all of them unattributable at once.""" @@ -142,9 +139,6 @@ def test_deleting_a_workspace_with_members_is_still_refused(db, tenant_factory, assert refused.value.status_code == 409 -# --- organisational units ------------------------------------------------- - - def test_deleting_a_unit_keeps_the_row(db, workspace): with scoped_to(workspace.tenant_id): unit = org_unit_service.create( @@ -202,7 +196,6 @@ def test_a_parent_can_be_deleted_once_its_children_have_been(db, workspace): ) org_unit_service.delete(db, child) - # Would raise if the deleted child still counted. org_unit_service.delete(db, parent) assert parent.deleted_at is not None @@ -233,8 +226,6 @@ def test_a_deleted_unit_no_longer_narrows_an_administrator(db, workspace, org_unit_service.unassign(db, user=person, unit=unit) org_unit_service.delete(db, unit) - # The scope row survives — it is history — but it points at nothing, so - # it constrains nothing. widened = org_unit_service.visible_user_ids(db, person) assert widened is None or colleague.id in widened diff --git a/tests/test_sso.py b/tests/test_sso.py index f48e6fe..fa0616f 100644 --- a/tests/test_sso.py +++ b/tests/test_sso.py @@ -69,9 +69,6 @@ def _sign(payload: dict, secret: str = SECRET) -> str: return hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest() -# ------------------------------------------------------------- what is signed - - def test_the_whole_payload_is_covered_by_the_signature(db, handoff): """S-3. The signature covered four fields of an eleven-field payload. @@ -134,8 +131,6 @@ def test_the_payload_carries_replay_controls(db, handoff): payload = result["payload"] assert payload["nonce"] assert payload["expires_at"] > payload["issued_at"] - # A window long enough to survive a slow browser, short enough that a - # captured payload is worthless by the time it is replayed by hand. assert (payload["expires_at"] - payload["issued_at"]) <= 300_000 @@ -160,9 +155,6 @@ def test_the_app_id_header_names_the_module(db, handoff): assert result["headers"]["X-App-Id"] == handoff.module.module_id -# ------------------------------------------------------------- what it asserts - - def test_permissions_are_the_role_bounded_by_the_plan(db, handoff): """S-2, in its second location. @@ -246,7 +238,6 @@ def test_the_assigned_environment_is_the_one_used(db, handoff, environment_facto assert result["payload"]["environment"] == "staging" assert result["target_url"].startswith(staging.backend_base_url) - # Signed with staging's secret, not production's. assert result["headers"]["X-Signature"] == _sign(result["payload"], "staging-only") assert result["headers"]["X-Signature"] != _sign(result["payload"], SECRET) @@ -261,9 +252,6 @@ def test_the_trust_secret_never_appears_in_the_handoff(db, handoff): assert SECRET not in json.dumps(result) -# ------------------------------------------------------- grants, and their use - - def test_a_grant_is_good_exactly_once(db, handoff, fake_redis, module_signing_key): """Redeeming twice is the cheapest attack on a code that travels in a URL, where it lands in browser history, referrers and server logs.""" @@ -365,8 +353,6 @@ def test_the_exchanged_token_carries_the_bounded_permissions( import jwt - # Verified against the public half, the way a receiving module does it — - # decoding without verification would prove nothing about the signature. claims = jwt.decode( result["access_token"], module_signing_key, @@ -391,14 +377,6 @@ def test_redis_being_down_is_a_refusal_not_a_bypass(db, handoff, monkeypatch): assert exc.value.status_code == 503 -# ------------------------------------------------- what the exchange re-checks -# -# `exchange_grant` is called by the *module backend*, not through -# `get_current_user`. Every check the authentication middleware performs on an -# ordinary request — is this account still enabled, is this workspace still -# paying — is absent on this path unless the exchange performs it itself. - - def test_a_disabled_account_cannot_redeem_a_grant(db, handoff, fake_redis, module_signing_key): """Login refuses a disabled account and so does the middleware. This path diff --git a/tests/test_ssrf.py b/tests/test_ssrf.py index 1e4532a..e7944ba 100644 --- a/tests/test_ssrf.py +++ b/tests/test_ssrf.py @@ -21,13 +21,13 @@ from app.core.ssrf import ( @pytest.mark.parametrize( "host", [ - "127.0.0.1", # the platform itself + "127.0.0.1", "localhost", "0.0.0.0", - "10.0.0.5", # private ranges + "10.0.0.5", "192.168.1.1", "172.16.0.1", - "169.254.169.254", # cloud metadata: credentials, one request away + "169.254.169.254", "::1", "fd00::1", ], diff --git a/tests/test_structural_invariants.py b/tests/test_structural_invariants.py index fa123e6..b7c16f3 100644 --- a/tests/test_structural_invariants.py +++ b/tests/test_structural_invariants.py @@ -19,9 +19,6 @@ from .conftest import requires_db, requires_enforced_rls VERSIONS = pathlib.Path(__file__).resolve().parent.parent / "alembic" / "versions" -# ------------------------------------------------------------- the migrations - - def test_no_migration_tries_to_drop_an_unnamed_constraint(): """`op.drop_constraint(None, ...)` cannot be emitted at all. @@ -50,7 +47,6 @@ def test_every_migration_has_a_downgrade(): if len(body) < 2: missing.append(path.name) continue - # A downgrade that is only `pass` is a downgrade in name only. statements = [ line.strip() for line in body[1].splitlines() @@ -77,9 +73,6 @@ def test_the_migration_chain_is_linear(): assert not forks, f"more than one migration claims the same parent: {forks}" -# ------------------------------------------------------------------- the schema - - @requires_db @requires_enforced_rls def test_every_tenant_scoped_table_has_a_policy(db): @@ -140,9 +133,6 @@ def test_every_policy_is_forced(db): assert not unforced, f"row security enabled but not forced on: {unforced}" -# -------------------------------------------------------------------- the API - - @requires_db def test_only_the_expected_routes_are_public(): """Anything reachable without a session should be a deliberate decision. @@ -155,43 +145,21 @@ def test_only_the_expected_routes_are_public(): from app import create_app EXPECTED = { - "/", # service banner - "/health", # load balancers - "/.well-known/jwks.json", # public keys, by definition + "/", + "/health", + "/.well-known/jwks.json", "/api/auth/signin", "/api/auth/signup", - "/api/auth/refresh", # carries its own credential + "/api/auth/refresh", "/api/auth/forgot-password", "/api/auth/verify-otp", "/api/auth/reset-password-otp", - "/internal/sso/exchange", # authenticated by HMAC, not a session + "/internal/sso/exchange", - # Identity-provider login. Unauthenticated by necessity — this is how a - # signed-out visitor begins, and how an external identity becomes a - # session here — so each says as little as it can: - # - # providers a name and a slug, enough to render "Sign in with - # Contoso" and nothing about the workspace - # start creates a login state; issues no credential - # callback the one that mints a session, and therefore the one that - # checks the state, the nonce, PKCE, the signature, the - # issuer, the audience and the expiry before it does "/api/sso/idp/{tenant_domain}/providers", "/api/sso/idp/{tenant_domain}/{slug}/start", "/api/sso/idp/callback", - # Accepting an invitation. Unauthenticated by definition — the person - # has no account yet, which is the thing being fixed: - # - # preview echoes the address and the workspace name so somebody can - # tell they are in the right place; deliberately nothing - # about roles or other members, or a guessed token would be - # a way to read a workspace's staff list - # accept creates the account. Guarded by a 256-bit single-use token - # that is stored only as a SHA-256, and rate limited on the - # token so guessing is capped per target rather than per - # source. Issues no session: signing in afterwards keeps the - # second factor and the subscription checks in one place. "/api/invitations/preview", "/api/invitations/accept", } @@ -250,9 +218,6 @@ def test_no_route_is_an_empty_stub(): assert not stubs, f"routes with no implementation: {stubs}" -# ----------------------------------------------------- configuration precedence - - def test_an_explicit_environment_variable_beats_the_dotenv_files(): """The ordinary precedence, which this did not have. @@ -272,8 +237,6 @@ def test_an_explicit_environment_variable_beats_the_dotenv_files(): backend = Path(__file__).resolve().parent.parent target = "postgresql://postgres:postgres@localhost:5432/precedence_probe" - # A subprocess, because settings resolve once at import and this test is - # about what happens at import. result = subprocess.run( [sys.executable, "-c", "from app.config.settings import settings; print(settings.DATABASE_URL)"], diff --git a/tests/test_subscription_history.py b/tests/test_subscription_history.py index af459cc..a34c4a3 100644 --- a/tests/test_subscription_history.py +++ b/tests/test_subscription_history.py @@ -38,9 +38,6 @@ def snapshot_of(): return _make -# ------------------------------------------------------------- classification - - def test_an_untouched_subscription_records_nothing(db, snapshot_of): """Otherwise every unrelated edit — a logo, a name — files a subscription row.""" same = snapshot_of(end_date=TODAY) @@ -141,9 +138,6 @@ def test_a_plan_change_outranks_a_date_change(db, plan_factory, snapshot_of): assert history.classify(db, before, after) == "upgrade" -# -------------------------------------------------------------------- writing - - def test_a_recorded_change_keeps_both_sides(db, tenant_factory, plan_factory): """From what, to what. A row holding only the new value explains nothing.""" cheap = plan_factory(price=10) @@ -236,9 +230,6 @@ def test_history_belongs_to_its_own_workspace(db, tenant_factory, plan_factory): assert history.history_for(db, alpha.id) == [] -# ------------------------------------------------------------- through the API - - def test_updating_a_tenant_records_the_change(db, tenant_factory, plan_factory): """The path that matters: nobody calls `record` by hand in production.""" from app.schemas.auth.tenant_schema import TenantUpdate diff --git a/tests/test_subscription_lifecycle.py b/tests/test_subscription_lifecycle.py index 932bc24..b283fc3 100644 --- a/tests/test_subscription_lifecycle.py +++ b/tests/test_subscription_lifecycle.py @@ -28,9 +28,6 @@ pytestmark = requires_db TODAY = utc_today() -# ------------------------------------------------------------------ the dates - - def test_the_final_day_still_works(db, tenant_factory, plan_factory): """"Valid until 31 March" means the 31st works. @@ -59,9 +56,6 @@ def test_an_end_date_applies_even_without_a_plan(db, tenant_factory): assert resolve(tenant).state is SubscriptionState.EXPIRED -# ------------------------------------------------------------------ the grace - - def test_a_lapsed_subscription_enters_grace_rather_than_locking_out( db, tenant_factory, plan_factory ): @@ -118,9 +112,6 @@ def test_no_grace_configured_keeps_the_old_behaviour(db, tenant_factory, plan_fa assert resolve(tenant).state is SubscriptionState.EXPIRED -# --------------------------------------------------------- administrative states - - @pytest.mark.parametrize( "stored, expected", [ @@ -156,9 +147,6 @@ def test_no_plan_is_not_a_fault(db, tenant_factory): assert lifecycle.can_write is True -# ------------------------------------------------------------- one authority - - def test_the_entitlement_service_and_the_lifecycle_agree( db, tenant_factory, plan_factory ): @@ -202,9 +190,6 @@ def test_the_tenant_service_uses_the_same_boundary(db, tenant_factory, plan_fact assert resolved == TenantService.STATUS_ACTIVE, "the final day must not be expired" -# ----------------------------------------------------------------- reporting - - def test_the_summary_explains_the_state(db, tenant_factory, plan_factory): """So the interface can say why, rather than failing a save generically.""" plan = plan_factory(grace_period_days=10) diff --git a/tests/test_subscription_notices.py b/tests/test_subscription_notices.py index 35d604c..8d90953 100644 --- a/tests/test_subscription_notices.py +++ b/tests/test_subscription_notices.py @@ -67,9 +67,6 @@ def _kinds(db, tenant): } -# -------------------------------------------------------------- what is due - - def test_a_subscription_ending_soon_is_warned_about(db, workspace, postbox): """The whole point: while everything still works and renewing is easy.""" tenant = workspace(3) @@ -132,9 +129,6 @@ def test_a_missed_run_still_catches_up(db, workspace): assert [n.kind for n in pending] == [notices.EXPIRING_SOON] -# ---------------------------------------------------------- sending, exactly once - - def test_a_notice_is_sent_once(db, workspace, postbox): tenant = workspace(3) with unscoped(): @@ -169,9 +163,8 @@ def test_renewing_starts_a_fresh_cycle(db, workspace, postbox): tenant.end_date = TODAY + timedelta(days=368) db.flush() - notices.send(db) # far off now: nothing due + notices.send(db) - # A year later, the same threshold comes round again. tenant.end_date = TODAY + timedelta(days=2) db.flush() notices.send(db) @@ -212,7 +205,6 @@ def test_the_record_is_written_before_the_send(db, workspace, monkeypatch): seen_at_send_time = [] def inspect(to_email, subject, body): - # What the database already knows at the moment the send is attempted. seen_at_send_time.append( db.query(SubscriptionNotice) .filter(SubscriptionNotice.tenant_id == tenant.id) @@ -243,9 +235,6 @@ def test_a_workspace_with_nobody_to_tell_is_visible(db, workspace, postbox, capl assert _kinds(db, tenant) == {notices.EXPIRING_SOON} -# ---------------------------------------------------------------- the message - - def test_the_warning_says_what_will_happen(db, workspace, postbox): """"Your subscription is ending" without "and then what" is a message that gets deferred.""" @@ -277,9 +266,6 @@ def test_the_expired_message_says_the_data_is_still_there(db, workspace, postbox assert "unchanged" in body or "restored" in body -# ------------------------------------------------------------ configuring it - - def test_a_billing_address_can_be_set_when_a_workspace_is_created(db, plan_factory): """Without a way in, the column is a feature nobody can use — the same shape as `grace_period_days` before its schema field existed.""" diff --git a/tests/test_tenant_email.py b/tests/test_tenant_email.py index 2c60bf2..e417450 100644 --- a/tests/test_tenant_email.py +++ b/tests/test_tenant_email.py @@ -66,9 +66,6 @@ def allow_host(monkeypatch): ) -# --- the destination ------------------------------------------------------ - - @pytest.mark.parametrize("host", ["127.0.0.1", "10.0.0.5", "169.254.169.254"]) def test_an_internal_host_is_refused(client, headers, host): """The server is the one that connects. A name resolving inside the network @@ -115,9 +112,6 @@ def test_the_host_is_checked_again_on_every_send(monkeypatch, workspace, db): assert failure is not None -# --- saving, and not switching on ---------------------------------------- - - def test_saving_does_not_switch_it_on(client, headers, allow_host): """A typo that silently breaks every invitation until somebody notices is the failure this ordering prevents.""" @@ -172,9 +166,6 @@ def test_a_failed_test_leaves_it_off_and_says_why(client, headers, monkeypatch, assert "bad credentials" in tested.json()["last_error"] -# --- the password --------------------------------------------------------- - - def test_the_password_is_never_returned(client, headers, allow_host): saved = client.put("/api/settings/email", headers=headers, json=_payload()) body = saved.text @@ -211,9 +202,6 @@ def test_omitting_the_password_keeps_the_stored_one(client, headers, db, assert settings.smtp_password == "hunter2" -# --- falling back --------------------------------------------------------- - - def test_nothing_configured_means_the_platform_sends(db, workspace): assert tenant_email_service.for_tenant(db, workspace.tenant_id) is None @@ -288,9 +276,6 @@ def test_a_working_relay_is_used_and_the_platform_is_not(db, workspace, assert sent == ["invitee@example.com"] -# --- the boundary --------------------------------------------------------- - - def test_one_workspace_cannot_read_another_s_settings(client, headers, db, workspace, allow_host, tenant_factory, diff --git a/tests/test_tenant_isolation.py b/tests/test_tenant_isolation.py index f5885d2..0573a3a 100644 --- a/tests/test_tenant_isolation.py +++ b/tests/test_tenant_isolation.py @@ -42,9 +42,6 @@ def two_workspaces(tenant_factory, role_factory, user_factory): return SimpleNamespace(alpha=build("Alpha"), bravo=build("Bravo")) -# ----------------------------------------------------------------------- roles - - def test_a_role_from_another_workspace_cannot_be_read(db, two_workspaces): """S-5: `get_role_by_id` filtered on the role id alone.""" with pytest.raises(HTTPException) as exc: @@ -54,8 +51,6 @@ def test_a_role_from_another_workspace_cannot_be_read(db, two_workspaces): actor_tenant_id=two_workspaces.alpha.tenant.id, actor_is_superadmin=False, ) - # 404 rather than 403: a cross-workspace id must not be distinguishable from - # one that does not exist, or the error itself becomes an enumeration oracle. assert exc.value.status_code == 404 @@ -108,7 +103,6 @@ def test_scoping_arguments_have_no_defaults(db): def test_a_superadmin_may_reach_any_workspace(db, two_workspaces): - # A superadmin bypasses, which is how `scoped_to(None)` is defined. with scoped_to(None): role = RoleService.get_role_by_id( db, @@ -136,9 +130,6 @@ def test_a_tenantless_non_superadmin_is_refused_outright(db, two_workspaces): assert exc.value.status_code == 403 -# ----------------------------------------------------------------------- users - - def test_a_user_from_another_workspace_cannot_be_read(db, two_workspaces): with pytest.raises(HTTPException) as exc: UserService.get_user_by_id( @@ -185,16 +176,9 @@ def test_a_role_from_another_workspace_cannot_be_assigned(db, two_workspaces): UserUpdate(role_id=two_workspaces.bravo.role.id), two_workspaces.alpha.tenant.id, ) - # The database now hides the role before the application's own check runs, - # so this is "Role not found" rather than "Cannot assign a role from another - # tenant". That is the better message as well as the stronger mechanism: it - # does not confirm that the role exists somewhere else. assert exc.value.status_code in (400, 403, 404) -# --------------------------------------------------- isolation is now structural - - @requires_enforced_rls def test_an_unscoped_lookup_no_longer_crosses_the_boundary(db, two_workspaces): """This test used to document a gap. Row-level security closed it. diff --git a/tests/test_theme.py b/tests/test_theme.py index c4d14aa..ea5cb02 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -77,9 +77,6 @@ def _defaults(db): return db.query(ColorPalette).filter(ColorPalette.is_default.is_(True)).all() -# ------------------------------------------------------------------- creating - - def test_a_palette_stores_its_colours(db): with unscoped(): palette = PaletteService.create_palette( @@ -125,9 +122,6 @@ def test_promoting_a_palette_demotes_the_previous_one(db): assert len(_defaults(db)) == 1 -# ------------------------------------------------------------------- updating - - def test_an_update_that_omits_colours_leaves_them_alone(db): """Otherwise renaming a palette wipes it.""" with unscoped(): @@ -146,9 +140,6 @@ def test_an_unknown_palette_is_a_404(db): assert exc.value.status_code == 404 -# -------------------------------------------------- there is always a default - - def test_the_only_default_cannot_be_demoted(db): """The console falls back to the default when a user has not chosen one. diff --git a/tests/test_trust_and_assignments.py b/tests/test_trust_and_assignments.py index 2a5e598..ec9b34e 100644 --- a/tests/test_trust_and_assignments.py +++ b/tests/test_trust_and_assignments.py @@ -35,9 +35,6 @@ def _sign(body: str, secret: str = SECRET) -> str: return hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest() -# --------------------------------------------------------------------- trust - - def test_a_correct_signature_is_accepted(db, module_factory, environment_factory): module = module_factory() env = environment_factory(module, secret=SECRET) @@ -183,9 +180,6 @@ def test_signing_a_payload_needs_a_secret(db, module_factory, environment_factor TrustService.sign_payload(env, "hello") -# --------------------------------------------------------------- assignments - - def test_a_module_can_be_given_to_a_workspace(db, tenant_factory, module_factory, environment_factory): tenant = tenant_factory() @@ -341,18 +335,6 @@ def test_turning_a_module_off_records_when(db, tenant_factory, module_factory, assert back.deactivated_at is None -# ------------------------------------------------------------ inbound replay -# -# The outbound handoff has a nonce and an expiry; the inbound direction had -# neither, so the same signed body could be presented twice. That is harmless -# today — the only inbound endpoint is grant exchange, and grants are deleted on -# use — and stops being harmless the moment a second, non-idempotent inbound -# endpoint exists. -# -# Version 2 is opt-in on purpose. Deployed modules do not send the new headers -# yet, and requiring them without warning would refuse every legitimate call. - - def _sign_v2(timestamp, nonce, body, secret=SECRET): return _sign(f"{timestamp}.{nonce}.{body}", secret) @@ -440,7 +422,6 @@ def test_the_timestamp_and_nonce_are_inside_the_signature(db, module_factory, body = "{}" headers = _v2_headers(int(time.time()), _uuid.uuid4().hex, body) - # A fresh nonce, kept alongside the original signature. headers["X-Module-Nonce"] = _uuid.uuid4().hex with pytest.raises(HTTPException) as exc: TrustService.validate_module_trust( @@ -557,7 +538,6 @@ def test_a_nonce_is_not_burned_by_an_unauthenticated_caller(db, module_factory, environment=env, request_headers=forged, request_body=body ) - # The real client's request, carrying the same nonce, still works. TrustService.validate_module_trust( environment=env, request_headers=_v2_headers(now, nonce, body), diff --git a/tests/test_user_and_tenant_lifecycle.py b/tests/test_user_and_tenant_lifecycle.py index f997bc4..9cef582 100644 --- a/tests/test_user_and_tenant_lifecycle.py +++ b/tests/test_user_and_tenant_lifecycle.py @@ -39,13 +39,8 @@ def _new_user(email: str | None = None, **overrides): ) -# ------------------------------------------------------------ creating a user - - def test_a_user_is_created_with_a_hashed_password(db, tenant_factory): tenant = tenant_factory() - # Asserted inside the scope: create_user commits, which expires the - # instance, and the reload that follows is policed like any other read. with scoped_to(tenant.id): user = UserService.create_user(db, _new_user("fresh@example.com"), tenant.id) @@ -103,9 +98,6 @@ def test_creating_a_user_does_not_leak_whose_workspace_holds_the_address( assert "Bravo" not in str(exc.value.detail) -# ------------------------------------------------------------ changing a user - - def test_a_users_workspace_cannot_be_changed_from_inside_it(db, tenant_factory, user_factory): """Moving an account between workspaces is not an edit, it is a transfer of @@ -168,9 +160,6 @@ def test_an_unknown_role_is_refused(db, tenant_factory, user_factory): assert exc.value.status_code in (400, 403, 404) -# ------------------------------------------------------------ removing a user - - def test_deleting_a_user_ends_their_sessions(db, tenant_factory, user_factory): """Otherwise a deleted account's refresh token outlives the account. @@ -219,16 +208,10 @@ def test_deleting_a_user_keeps_the_record_of_what_they_changed( entries = history.history_for(db, tenant.id) assert len(entries) == 1 - # Both survive now that deletion is soft. It used to be the address alone, - # because the row went and the foreign key was set to NULL; keeping the row - # keeps the link as well, which is strictly more than the trail had before. assert entries[0].changed_by_id is not None assert entries[0].changed_by_email == actor_email -# ------------------------------------------------------- removing a workspace - - def test_deleting_a_workspace_does_not_destroy_its_users(db, tenant_factory, role_factory, user_factory): """`Tenant.users` cascades delete-orphan. @@ -266,9 +249,6 @@ def test_an_empty_workspace_can_be_deleted(db, tenant_factory): assert exc.value.status_code == 404 -# ---------------------------------------------------------- workspace reading - - def test_reading_a_workspace_settles_its_derived_status(db, tenant_factory, plan_factory): """A lapsed workspace whose stored status was never updated reads as diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index f781393..b7c87a4 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -89,15 +89,12 @@ def _register(client, headers, **extra): return response.json() -# --- the destination ------------------------------------------------------ - - @pytest.mark.parametrize("url", [ - "http://169.254.169.254/latest/meta-data/", # cloud metadata - "https://127.0.0.1/hook", # this machine - "https://10.0.0.5/hook", # inside the network - "https://user:pass@example.com/hook", # credentials in the URL - "ftp://example.com/hook", # not a scheme we speak + "http://169.254.169.254/latest/meta-data/", + "https://127.0.0.1/hook", + "https://10.0.0.5/hook", + "https://user:pass@example.com/hook", + "ftp://example.com/hook", ]) def test_an_unsafe_destination_is_refused_at_the_form(client, headers, url): """The server is the one that fetches this. Unchecked, a registration form @@ -138,9 +135,6 @@ def test_the_destination_is_rechecked_on_every_attempt(db, workspace, assert "unsafe" in (endpoint.disabled_reason or "").lower() -# --- the signature -------------------------------------------------------- - - def test_the_signature_is_verifiable_the_way_a_receiver_would(allow_outbound, captured_posts, db, workspace): @@ -202,14 +196,9 @@ def test_the_body_is_signed_exactly_as_it_is_sent(allow_outbound, captured_posts webhook_service.send_one(db, delivery) sent = captured_posts[-1] - # The body parses back to what was queued, and the signature was computed - # over that exact string. assert json.loads(sent["body"]) == delivery.payload -# --- emitting ------------------------------------------------------------- - - def test_creating_a_user_queues_an_event(client, headers, db, workspace, allow_outbound): _register(client, headers) @@ -319,9 +308,6 @@ def test_an_invitation_event_never_carries_the_token(client, headers, db, assert all(raw not in json.dumps(r.payload) for r in rows) -# --- failure and retry ---------------------------------------------------- - - def test_a_failure_backs_off_rather_than_hammering(allow_outbound, db, workspace, monkeypatch): monkeypatch.setattr("app.services.system.webhook_service.httpx.post", @@ -444,9 +430,6 @@ def test_re_enabling_clears_the_count_too(client, headers, db, workspace, assert body["disabled_reason"] is None -# --- the secret and the boundary ----------------------------------------- - - def test_the_secret_is_shown_on_creation_and_not_in_listings(client, headers, allow_outbound): created = _register(client, headers) @@ -531,9 +514,6 @@ def test_every_webhook_route_needs_a_session(client): assert client.post("/api/webhooks", json={"url": URL}).status_code in (401, 403) -# --- the delivery log ----------------------------------------------------- - - def test_deliveries_are_visible_to_the_customer(client, headers, db, workspace, allow_outbound): """The single most useful screen when a customer says "we never got it" — @@ -610,7 +590,6 @@ def test_a_subscription_change_queues_an_event(client, headers, db, workspace, with unscoped(): before = subscription_history.snapshot(workspace.tenant) - # No edits: nothing to tell anybody about. subscription_history.record(db, workspace.tenant, before) db.flush() diff --git a/tests/test_worker.py b/tests/test_worker.py index c7da732..aa150ee 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -22,9 +22,6 @@ def _job(name: str, interval: float, run=None) -> Job: return Job(name, interval, run or (lambda: f"{name} ran")) -# --------------------------------------------------------------- what runs - - def test_everything_runs_on_the_first_pass(): """A process that keeps being restarted must still reach the slow jobs. @@ -65,16 +62,12 @@ def test_an_overrunning_job_does_not_immediately_become_due_again(): jobs = (_job("slow", 5),) tick(schedule, now=0.0, jobs=jobs) - # Nine intervals' worth of clock passed inside the job. tick(schedule, now=45.0, jobs=jobs) assert tick(schedule, now=46.0, jobs=jobs) == {} assert set(tick(schedule, now=51.0, jobs=jobs)) == {"slow"} -# ------------------------------------------------------------- when one fails - - def test_a_failing_job_does_not_stop_the_others(): """The defect this file exists for. @@ -167,9 +160,6 @@ def test_a_failure_is_logged_with_its_traceback(caplog): assert records[0].exc_info is not None -# -------------------------------------------------------- the real job table - - def test_the_intervals_are_ordered_by_urgency(): """A sanity check on the table rather than on the mechanism: the outbox is what a customer waits on, and housekeeping is not.""" @@ -188,10 +178,6 @@ def test_every_job_is_callable_and_named(): assert job.interval > 0 -# `audit-retention` is the one job that does not take a session from the -# application's pool: the application role cannot delete audit entries, so it -# opens its own connection on the retention role. The test below holds it to the -# same standard through the door it actually uses. SESSION_JOBS = [j for j in worker.JOBS if j.name != "audit-retention"] @@ -208,8 +194,6 @@ def test_every_job_closes_its_session(job, monkeypatch): class FakeSession: def __getattr__(self, name): - # Every job does something different with it; none of that matters - # here, only that close() happens. return lambda *a, **kw: 0 if name != "query" else FakeSession() def close(self): @@ -220,7 +204,6 @@ def test_every_job_closes_its_session(job, monkeypatch): try: job.run() except Exception: - # A stub session makes some jobs throw. Closing is the assertion. pass assert closed, f"{job.name} did not close its session" @@ -266,7 +249,6 @@ def test_the_retention_job_closes_its_own_connection(monkeypatch): try: worker.prune_audit_log() except Exception: - # A stub session makes the sweep throw. Closing is the assertion. pass assert closed, "the retention session was not closed"