diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 3f79d00..3da20ab 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -9,6 +9,7 @@ from app.models.config_template import ConfigTemplate, ConfigTemplateTag, Templa from app.models.audit_log import AuditLog from app.models.maintenance_window import MaintenanceWindow from app.models.api_key import ApiKey +from app.models.config_backup import RouterConfigSnapshot, RouterConfigDiff, RouterConfigChange __all__ = [ "Tenant", @@ -32,4 +33,7 @@ __all__ = [ "AuditLog", "MaintenanceWindow", "ApiKey", + "RouterConfigSnapshot", + "RouterConfigDiff", + "RouterConfigChange", ] diff --git a/backend/app/models/config_backup.py b/backend/app/models/config_backup.py index fe09d3b..edc3dd2 100644 --- a/backend/app/models/config_backup.py +++ b/backend/app/models/config_backup.py @@ -176,3 +176,164 @@ class ConfigPushOperation(Base): f"" ) + + +class RouterConfigSnapshot(Base): + """A point-in-time router configuration snapshot. + + The config_text column stores OpenBao Transit ciphertext (vault:v1:...). + Plaintext router config is NEVER stored in PostgreSQL -- it is encrypted + via Transit before insertion and decrypted on read. + + The sha256_hash column stores the SHA-256 hex digest of the PLAINTEXT + config (computed before encryption). This enables deduplication: if + the hash matches the latest snapshot for a device, no new row is created. + """ + + __tablename__ = "router_config_snapshots" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + device_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("devices.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + # OpenBao Transit ciphertext (vault:v1:...). Plaintext NEVER stored. + config_text: Mapped[str] = mapped_column(Text, nullable=False) + # SHA-256 hex digest of the PLAINTEXT config, for deduplication. + sha256_hash: Mapped[str] = mapped_column(String(64), nullable=False) + collected_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + def __repr__(self) -> str: + return ( + f"" + ) + + +class RouterConfigDiff(Base): + """Unified diff between two consecutive router config snapshots. + + Stores the diff_text (unified diff output) along with line counts + for quick display without re-parsing. References both the old and + new snapshot by foreign key. + """ + + __tablename__ = "router_config_diffs" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + device_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("devices.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + old_snapshot_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("router_config_snapshots.id", ondelete="CASCADE"), + nullable=False, + ) + new_snapshot_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("router_config_snapshots.id", ondelete="CASCADE"), + nullable=False, + ) + diff_text: Mapped[str] = mapped_column(Text, nullable=False) + lines_added: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + lines_removed: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + def __repr__(self) -> str: + return ( + f"" + ) + + +class RouterConfigChange(Base): + """A parsed change extracted from a router config diff. + + Each change represents a semantic modification (e.g., firewall rule + added, IP address changed) parsed from the unified diff. The component + field identifies the RouterOS section (e.g., 'ip/firewall/filter'). + """ + + __tablename__ = "router_config_changes" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + diff_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("router_config_diffs.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + device_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("devices.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + tenant_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tenants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + # RouterOS config section path (e.g., 'ip/firewall/filter') + component: Mapped[str] = mapped_column(Text, nullable=False) + # Human-readable description of the change + summary: Mapped[str] = mapped_column(Text, nullable=False) + # Raw diff line(s), nullable for synthesized changes + raw_line: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/backend/tests/unit/test_config_snapshot_models.py b/backend/tests/unit/test_config_snapshot_models.py new file mode 100644 index 0000000..b90e251 --- /dev/null +++ b/backend/tests/unit/test_config_snapshot_models.py @@ -0,0 +1,93 @@ +"""Unit tests for RouterConfigSnapshot, RouterConfigDiff, RouterConfigChange models. + +Verifies STOR-01 (table/column structure) and STOR-05 (config_text stores ciphertext). +""" + +import uuid + +from sqlalchemy import String, Text +from sqlalchemy.dialects.postgresql import UUID + + +def test_router_config_snapshot_importable(): + """RouterConfigSnapshot can be imported from app.models.""" + from app.models import RouterConfigSnapshot + assert RouterConfigSnapshot is not None + + +def test_router_config_diff_importable(): + """RouterConfigDiff can be imported from app.models.""" + from app.models import RouterConfigDiff + assert RouterConfigDiff is not None + + +def test_router_config_change_importable(): + """RouterConfigChange can be imported from app.models.""" + from app.models import RouterConfigChange + assert RouterConfigChange is not None + + +def test_snapshot_tablename(): + """RouterConfigSnapshot.__tablename__ is correct.""" + from app.models import RouterConfigSnapshot + assert RouterConfigSnapshot.__tablename__ == "router_config_snapshots" + + +def test_diff_tablename(): + """RouterConfigDiff.__tablename__ is correct.""" + from app.models import RouterConfigDiff + assert RouterConfigDiff.__tablename__ == "router_config_diffs" + + +def test_change_tablename(): + """RouterConfigChange.__tablename__ is correct.""" + from app.models import RouterConfigChange + assert RouterConfigChange.__tablename__ == "router_config_changes" + + +def test_snapshot_columns(): + """RouterConfigSnapshot has all required columns.""" + from app.models import RouterConfigSnapshot + table = RouterConfigSnapshot.__table__ + expected = {"id", "device_id", "tenant_id", "config_text", "sha256_hash", "collected_at", "created_at"} + actual = {c.name for c in table.columns} + assert expected.issubset(actual), f"Missing columns: {expected - actual}" + + +def test_diff_columns(): + """RouterConfigDiff has all required columns.""" + from app.models import RouterConfigDiff + table = RouterConfigDiff.__table__ + expected = { + "id", "device_id", "tenant_id", "old_snapshot_id", "new_snapshot_id", + "diff_text", "lines_added", "lines_removed", "created_at", + } + actual = {c.name for c in table.columns} + assert expected.issubset(actual), f"Missing columns: {expected - actual}" + + +def test_change_columns(): + """RouterConfigChange has all required columns.""" + from app.models import RouterConfigChange + table = RouterConfigChange.__table__ + expected = { + "id", "diff_id", "device_id", "tenant_id", + "component", "summary", "raw_line", "created_at", + } + actual = {c.name for c in table.columns} + assert expected.issubset(actual), f"Missing columns: {expected - actual}" + + +def test_snapshot_config_text_is_text_type(): + """config_text column type is Text (documents Transit ciphertext contract).""" + from app.models import RouterConfigSnapshot + col = RouterConfigSnapshot.__table__.c.config_text + assert isinstance(col.type, Text), f"Expected Text, got {type(col.type)}" + + +def test_snapshot_sha256_hash_is_string_64(): + """sha256_hash column type is String(64) for plaintext hash deduplication.""" + from app.models import RouterConfigSnapshot + col = RouterConfigSnapshot.__table__.c.sha256_hash + assert isinstance(col.type, String), f"Expected String, got {type(col.type)}" + assert col.type.length == 64, f"Expected length 64, got {col.type.length}"