diff --git a/backend/alembic/versions/037_credential_profiles_table.py b/backend/alembic/versions/037_credential_profiles_table.py index 097edaf..8aabe93 100644 --- a/backend/alembic/versions/037_credential_profiles_table.py +++ b/backend/alembic/versions/037_credential_profiles_table.py @@ -41,12 +41,8 @@ def upgrade() -> None: """) ) - conn.execute( - sa.text("ALTER TABLE credential_profiles ENABLE ROW LEVEL SECURITY") - ) - conn.execute( - sa.text("ALTER TABLE credential_profiles FORCE ROW LEVEL SECURITY") - ) + conn.execute(sa.text("ALTER TABLE credential_profiles ENABLE ROW LEVEL SECURITY")) + conn.execute(sa.text("ALTER TABLE credential_profiles FORCE ROW LEVEL SECURITY")) conn.execute( sa.text(""" @@ -63,20 +59,13 @@ def upgrade() -> None: """) ) - conn.execute( - sa.text("GRANT SELECT ON credential_profiles TO poller_user") - ) - conn.execute( - sa.text("GRANT SELECT, INSERT, UPDATE, DELETE ON credential_profiles TO app_user") - ) + conn.execute(sa.text("GRANT SELECT ON credential_profiles TO poller_user")) + conn.execute(sa.text("GRANT SELECT, INSERT, UPDATE, DELETE ON credential_profiles TO app_user")) def downgrade() -> None: conn = op.get_bind() conn.execute( - sa.text( - "DROP POLICY IF EXISTS credential_profiles_tenant_isolation" - " ON credential_profiles" - ) + sa.text("DROP POLICY IF EXISTS credential_profiles_tenant_isolation ON credential_profiles") ) op.drop_table("credential_profiles") diff --git a/backend/alembic/versions/038_snmp_profiles_table.py b/backend/alembic/versions/038_snmp_profiles_table.py index d417635..577198e 100644 --- a/backend/alembic/versions/038_snmp_profiles_table.py +++ b/backend/alembic/versions/038_snmp_profiles_table.py @@ -630,12 +630,8 @@ def upgrade() -> None: ) # -- RLS: system profiles visible to all tenants ----------------------- - conn.execute( - sa.text("ALTER TABLE snmp_profiles ENABLE ROW LEVEL SECURITY") - ) - conn.execute( - sa.text("ALTER TABLE snmp_profiles FORCE ROW LEVEL SECURITY") - ) + conn.execute(sa.text("ALTER TABLE snmp_profiles ENABLE ROW LEVEL SECURITY")) + conn.execute(sa.text("ALTER TABLE snmp_profiles FORCE ROW LEVEL SECURITY")) conn.execute( sa.text(""" CREATE POLICY snmp_profiles_tenant_isolation @@ -648,12 +644,8 @@ def upgrade() -> None: """) ) - conn.execute( - sa.text("GRANT SELECT ON snmp_profiles TO poller_user") - ) - conn.execute( - sa.text("GRANT SELECT, INSERT, UPDATE, DELETE ON snmp_profiles TO app_user") - ) + conn.execute(sa.text("GRANT SELECT ON snmp_profiles TO poller_user")) + conn.execute(sa.text("GRANT SELECT, INSERT, UPDATE, DELETE ON snmp_profiles TO app_user")) # -- Seed 6 system profiles -------------------------------------------- for profile in SEED_PROFILES: @@ -679,10 +671,5 @@ def upgrade() -> None: def downgrade() -> None: conn = op.get_bind() - conn.execute( - sa.text( - "DROP POLICY IF EXISTS snmp_profiles_tenant_isolation" - " ON snmp_profiles" - ) - ) + conn.execute(sa.text("DROP POLICY IF EXISTS snmp_profiles_tenant_isolation ON snmp_profiles")) op.drop_table("snmp_profiles") diff --git a/backend/alembic/versions/039_devices_snmp_columns.py b/backend/alembic/versions/039_devices_snmp_columns.py index c83cd23..18f2741 100644 --- a/backend/alembic/versions/039_devices_snmp_columns.py +++ b/backend/alembic/versions/039_devices_snmp_columns.py @@ -29,25 +29,12 @@ def upgrade() -> None: conn.execute(sa.text("SET lock_timeout = '3s'")) conn.execute( - sa.text( - "ALTER TABLE devices" - " ADD COLUMN device_type TEXT NOT NULL DEFAULT 'routeros'" - ) + sa.text("ALTER TABLE devices ADD COLUMN device_type TEXT NOT NULL DEFAULT 'routeros'") ) - conn.execute( - sa.text( - "ALTER TABLE devices" - " ADD COLUMN snmp_port INTEGER DEFAULT 161" - ) - ) + conn.execute(sa.text("ALTER TABLE devices ADD COLUMN snmp_port INTEGER DEFAULT 161")) - conn.execute( - sa.text( - "ALTER TABLE devices" - " ADD COLUMN snmp_version TEXT" - ) - ) + conn.execute(sa.text("ALTER TABLE devices ADD COLUMN snmp_version TEXT")) conn.execute( sa.text( @@ -69,18 +56,8 @@ def upgrade() -> None: def downgrade() -> None: conn = op.get_bind() - conn.execute( - sa.text("ALTER TABLE devices DROP COLUMN IF EXISTS credential_profile_id") - ) - conn.execute( - sa.text("ALTER TABLE devices DROP COLUMN IF EXISTS snmp_profile_id") - ) - conn.execute( - sa.text("ALTER TABLE devices DROP COLUMN IF EXISTS snmp_version") - ) - conn.execute( - sa.text("ALTER TABLE devices DROP COLUMN IF EXISTS snmp_port") - ) - conn.execute( - sa.text("ALTER TABLE devices DROP COLUMN IF EXISTS device_type") - ) + conn.execute(sa.text("ALTER TABLE devices DROP COLUMN IF EXISTS credential_profile_id")) + conn.execute(sa.text("ALTER TABLE devices DROP COLUMN IF EXISTS snmp_profile_id")) + conn.execute(sa.text("ALTER TABLE devices DROP COLUMN IF EXISTS snmp_version")) + conn.execute(sa.text("ALTER TABLE devices DROP COLUMN IF EXISTS snmp_port")) + conn.execute(sa.text("ALTER TABLE devices DROP COLUMN IF EXISTS device_type")) diff --git a/backend/alembic/versions/040_snmp_metrics_hypertable.py b/backend/alembic/versions/040_snmp_metrics_hypertable.py index 09bbebf..70e0114 100644 --- a/backend/alembic/versions/040_snmp_metrics_hypertable.py +++ b/backend/alembic/versions/040_snmp_metrics_hypertable.py @@ -44,11 +44,7 @@ def upgrade() -> None: conn.execute(sa.text("SELECT create_hypertable('snmp_metrics', 'time')")) - conn.execute( - sa.text( - "SELECT add_retention_policy('snmp_metrics', INTERVAL '90 days')" - ) - ) + conn.execute(sa.text("SELECT add_retention_policy('snmp_metrics', INTERVAL '90 days')")) conn.execute( sa.text(""" @@ -57,12 +53,8 @@ def upgrade() -> None: """) ) - conn.execute( - sa.text("ALTER TABLE snmp_metrics ENABLE ROW LEVEL SECURITY") - ) - conn.execute( - sa.text("ALTER TABLE snmp_metrics FORCE ROW LEVEL SECURITY") - ) + conn.execute(sa.text("ALTER TABLE snmp_metrics ENABLE ROW LEVEL SECURITY")) + conn.execute(sa.text("ALTER TABLE snmp_metrics FORCE ROW LEVEL SECURITY")) conn.execute( sa.text(""" @@ -75,9 +67,7 @@ def upgrade() -> None: """) ) - conn.execute( - sa.text("GRANT SELECT, INSERT ON snmp_metrics TO app_user") - ) + conn.execute(sa.text("GRANT SELECT, INSERT ON snmp_metrics TO app_user")) def downgrade() -> None: diff --git a/backend/app/routers/credential_profiles.py b/backend/app/routers/credential_profiles.py index 0342fbc..ecc29c3 100644 --- a/backend/app/routers/credential_profiles.py +++ b/backend/app/routers/credential_profiles.py @@ -113,7 +113,10 @@ async def update_profile( """Update a credential profile. Requires operator role or above.""" await _check_tenant_access(current_user, tenant_id, db) return await credential_profile_service.update_profile( - db=db, tenant_id=tenant_id, profile_id=profile_id, data=data, + db=db, + tenant_id=tenant_id, + profile_id=profile_id, + data=data, user_id=current_user.user_id, ) @@ -136,7 +139,9 @@ async def delete_profile( """ await _check_tenant_access(current_user, tenant_id, db) await credential_profile_service.delete_profile( - db=db, tenant_id=tenant_id, profile_id=profile_id, + db=db, + tenant_id=tenant_id, + profile_id=profile_id, user_id=current_user.user_id, ) diff --git a/backend/app/routers/snmp_profiles.py b/backend/app/routers/snmp_profiles.py index 1ed358e..db6dfe6 100644 --- a/backend/app/routers/snmp_profiles.py +++ b/backend/app/routers/snmp_profiles.py @@ -31,7 +31,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database import get_db -from app.middleware.rbac import require_operator_or_above, require_scope, require_tenant_admin_or_above +from app.middleware.rbac import ( + require_operator_or_above, + require_scope, + require_tenant_admin_or_above, +) from app.middleware.tenant_context import CurrentUser, get_current_user from app.routers.devices import _check_tenant_access from app.schemas.snmp_profile import ( @@ -252,7 +256,7 @@ async def update_profile( sql = f""" UPDATE snmp_profiles - SET {', '.join(set_clauses)} + SET {", ".join(set_clauses)} WHERE id = :profile_id AND tenant_id = :tenant_id RETURNING id, tenant_id, name, description, sys_object_id, vendor, category, is_system, created_at, updated_at diff --git a/backend/app/schemas/credential_profile.py b/backend/app/schemas/credential_profile.py index a5b6b0e..4e430e6 100644 --- a/backend/app/schemas/credential_profile.py +++ b/backend/app/schemas/credential_profile.py @@ -46,9 +46,7 @@ class CredentialProfileCreate(BaseModel): @classmethod def validate_credential_type(cls, v: str) -> str: if v not in VALID_CREDENTIAL_TYPES: - raise ValueError( - f"credential_type must be one of: {', '.join(VALID_CREDENTIAL_TYPES)}" - ) + raise ValueError(f"credential_type must be one of: {', '.join(VALID_CREDENTIAL_TYPES)}") return v @model_validator(mode="after") @@ -141,9 +139,7 @@ class CredentialProfileUpdate(BaseModel): if v is None: return v if v not in VALID_CREDENTIAL_TYPES: - raise ValueError( - f"credential_type must be one of: {', '.join(VALID_CREDENTIAL_TYPES)}" - ) + raise ValueError(f"credential_type must be one of: {', '.join(VALID_CREDENTIAL_TYPES)}") return v @model_validator(mode="after") @@ -151,9 +147,14 @@ class CredentialProfileUpdate(BaseModel): """Validate credential fields only when credential_type or credential fields change.""" # Collect which credential fields were provided cred_fields = { - "username", "password", "community", - "security_level", "auth_protocol", "auth_passphrase", - "priv_protocol", "priv_passphrase", + "username", + "password", + "community", + "security_level", + "auth_protocol", + "auth_passphrase", + "priv_protocol", + "priv_passphrase", } has_cred_changes = any(getattr(self, f) is not None for f in cred_fields) diff --git a/backend/app/services/credential_profile_service.py b/backend/app/services/credential_profile_service.py index a55144c..411f740 100644 --- a/backend/app/services/credential_profile_service.py +++ b/backend/app/services/credential_profile_service.py @@ -65,7 +65,9 @@ def _build_credential_json(data: CredentialProfileCreate | CredentialProfileUpda raise ValueError(f"Unknown credential_type: {ct}") -def _profile_response(profile: CredentialProfile, device_count: int = 0) -> CredentialProfileResponse: +def _profile_response( + profile: CredentialProfile, device_count: int = 0 +) -> CredentialProfileResponse: """Build a CredentialProfileResponse from an ORM instance.""" return CredentialProfileResponse( id=profile.id, @@ -116,9 +118,11 @@ async def get_profiles( credential_type: str | None = None, ) -> CredentialProfileListResponse: """List all credential profiles for a tenant.""" - query = select(CredentialProfile).where( - CredentialProfile.tenant_id == tenant_id - ).order_by(CredentialProfile.name) + query = ( + select(CredentialProfile) + .where(CredentialProfile.tenant_id == tenant_id) + .order_by(CredentialProfile.name) + ) if credential_type: query = query.where(CredentialProfile.credential_type == credential_type) @@ -141,10 +145,7 @@ async def get_profiles( for row in count_result: device_counts[row.credential_profile_id] = row.cnt - responses = [ - _profile_response(p, device_count=device_counts.get(p.id, 0)) - for p in profiles - ] + responses = [_profile_response(p, device_count=device_counts.get(p.id, 0)) for p in profiles] return CredentialProfileListResponse(profiles=responses) @@ -211,9 +212,14 @@ async def update_profile( # Determine if credential re-encryption is needed cred_fields = { - "username", "password", "community", - "security_level", "auth_protocol", "auth_passphrase", - "priv_protocol", "priv_passphrase", + "username", + "password", + "community", + "security_level", + "auth_protocol", + "auth_passphrase", + "priv_protocol", + "priv_passphrase", } has_cred_changes = any(getattr(data, f) is not None for f in cred_fields) type_changed = data.credential_type is not None @@ -241,13 +247,18 @@ async def update_profile( action="credential_profile.update", resource_type="credential_profile", resource_id=str(profile.id), - details={"name": profile.name, "updated_fields": list(data.model_dump(exclude_unset=True).keys())}, + details={ + "name": profile.name, + "updated_fields": list(data.model_dump(exclude_unset=True).keys()), + }, ) return _profile_response(profile, device_count=dc) -def _merge_update(data: CredentialProfileUpdate, profile: CredentialProfile) -> CredentialProfileUpdate: +def _merge_update( + data: CredentialProfileUpdate, profile: CredentialProfile +) -> CredentialProfileUpdate: """For partial credential updates, overlay data onto existing profile type. When credential_type is not changing but individual credential fields are,