IPAM Manager: API, UI-админка, журнал аудита
Backend (FastAPI, SQLAlchemy 2, Alembic, PostgreSQL 16): - организации, VRF, префиксы (дерево, использование, автоназначение), адреса, операторы связи, устройства и типы устройств; JWT, роли admin/viewer; - VRF принадлежит организации (составной FK), смена VRF у префикса переносит поддерево, имя VRF уникально в организации; - журнал аудита: поиск и фильтры, ротация (срок/количество), очистка по паролю с блокировкой, IP клиента и метаданные запроса (X-Forwarded-For только от TRUSTED_PROXIES). UI (web/, без сборки): экраны и диалоги по макетам «IPAM Manager», кликабельные строки реестров, локальные шрифты IBM Plex, собственные выпадающие списки. Окружение: docker-compose (postgres + app), миграции Alembic 0001-0004, scripts/gen_env.py, scripts/seed_demo.py, 11 автотестов (pytest). Документация: README.md и docs/changes/001-005 (планы и итоги). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
a846d30872
64 files changed
+4194
No files matched your search
@@ -0,0 +1,20 @@
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app.config import settings
|
||||
from app.models import Base
|
||||
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url.replace("%", "%%"))
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run():
|
||||
engine = engine_from_config(config.get_section(config.config_ini_section), prefix="sqlalchemy.", poolclass=pool.NullPool)
|
||||
with engine.connect() as conn:
|
||||
context.configure(connection=conn, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
run()
|
||||
@@ -0,0 +1,22 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
${imports if imports else ""}
|
||||
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,163 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = '0001'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('audit_log',
|
||||
sa.Column('id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('ts', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('username', sa.String(length=100), nullable=False),
|
||||
sa.Column('entity_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('entity_id', sa.Integer(), nullable=True),
|
||||
sa.Column('entity_label', sa.String(length=255), nullable=False),
|
||||
sa.Column('action', sa.String(length=20), nullable=False),
|
||||
sa.Column('diff', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_audit_log_entity_type'), 'audit_log', ['entity_type'], unique=False)
|
||||
op.create_index(op.f('ix_audit_log_ts'), 'audit_log', ['ts'], unique=False)
|
||||
op.create_table('device_types',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('is_default', sa.Boolean(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('name')
|
||||
)
|
||||
op.create_table('organizations',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('short_name', sa.String(length=100), nullable=False),
|
||||
sa.Column('inn', sa.String(length=12), nullable=False),
|
||||
sa.Column('address', sa.String(length=500), nullable=False),
|
||||
sa.Column('contact_person', sa.String(length=255), nullable=False),
|
||||
sa.Column('phone', sa.String(length=50), nullable=False),
|
||||
sa.Column('email', sa.String(length=255), nullable=False),
|
||||
sa.Column('note', sa.Text(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('inn'),
|
||||
sa.UniqueConstraint('name')
|
||||
)
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('username', sa.String(length=100), nullable=False),
|
||||
sa.Column('password_hash', sa.String(length=255), nullable=False),
|
||||
sa.Column('role', sa.Enum('admin', 'viewer', name='user_role'), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('username')
|
||||
)
|
||||
op.create_table('devices',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('device_type_id', sa.Integer(), nullable=False),
|
||||
sa.Column('organization_id', sa.Integer(), nullable=False),
|
||||
sa.Column('mac', sa.String(length=17), nullable=False),
|
||||
sa.Column('note', sa.Text(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['device_type_id'], ['device_types.id'], ),
|
||||
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('organization_id', 'name')
|
||||
)
|
||||
op.create_index(op.f('ix_devices_organization_id'), 'devices', ['organization_id'], unique=False)
|
||||
op.create_table('isps',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('organization_id', sa.Integer(), nullable=False),
|
||||
sa.Column('contract_number', sa.String(length=100), nullable=False),
|
||||
sa.Column('hotline', sa.String(length=50), nullable=False),
|
||||
sa.Column('note', sa.Text(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_isps_organization_id'), 'isps', ['organization_id'], unique=False)
|
||||
op.create_table('vrfs',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('organization_id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('route_target', sa.String(length=50), nullable=False),
|
||||
sa.Column('note', sa.Text(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('organization_id', 'name')
|
||||
)
|
||||
op.create_table('isp_networks',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('isp_id', sa.Integer(), nullable=False),
|
||||
sa.Column('cidr', postgresql.CIDR(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['isp_id'], ['isps.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_isp_networks_isp_id'), 'isp_networks', ['isp_id'], unique=False)
|
||||
op.create_table('prefixes',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('organization_id', sa.Integer(), nullable=False),
|
||||
sa.Column('vrf_id', sa.Integer(), nullable=False),
|
||||
sa.Column('prefix', postgresql.CIDR(), nullable=False),
|
||||
sa.Column('description', sa.String(length=500), nullable=False),
|
||||
sa.Column('status', sa.Enum('active', 'reserved', 'deprecated', name='prefix_status'), nullable=False),
|
||||
sa.Column('parent_id', sa.Integer(), nullable=True),
|
||||
sa.Column('is_pool', sa.Boolean(), nullable=False),
|
||||
sa.Column('note', sa.Text(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
|
||||
sa.ForeignKeyConstraint(['parent_id'], ['prefixes.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['vrf_id'], ['vrfs.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('vrf_id', 'prefix')
|
||||
)
|
||||
op.create_index(op.f('ix_prefixes_organization_id'), 'prefixes', ['organization_id'], unique=False)
|
||||
op.create_index(op.f('ix_prefixes_vrf_id'), 'prefixes', ['vrf_id'], unique=False)
|
||||
op.create_table('addresses',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('prefix_id', sa.Integer(), nullable=False),
|
||||
sa.Column('address', postgresql.INET(), nullable=False),
|
||||
sa.Column('status', sa.Enum('assigned', 'reserved', 'deprecated', name='address_status'), nullable=False),
|
||||
sa.Column('dns_name', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', sa.String(length=500), nullable=False),
|
||||
sa.Column('device_id', sa.Integer(), nullable=True),
|
||||
sa.Column('note', sa.Text(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['prefix_id'], ['prefixes.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('prefix_id', 'address')
|
||||
)
|
||||
op.create_index(op.f('ix_addresses_device_id'), 'addresses', ['device_id'], unique=False)
|
||||
op.create_index(op.f('ix_addresses_prefix_id'), 'addresses', ['prefix_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_addresses_prefix_id'), table_name='addresses')
|
||||
op.drop_index(op.f('ix_addresses_device_id'), table_name='addresses')
|
||||
op.drop_table('addresses')
|
||||
op.drop_index(op.f('ix_prefixes_vrf_id'), table_name='prefixes')
|
||||
op.drop_index(op.f('ix_prefixes_organization_id'), table_name='prefixes')
|
||||
op.drop_table('prefixes')
|
||||
op.drop_index(op.f('ix_isp_networks_isp_id'), table_name='isp_networks')
|
||||
op.drop_table('isp_networks')
|
||||
op.drop_table('vrfs')
|
||||
op.drop_index(op.f('ix_isps_organization_id'), table_name='isps')
|
||||
op.drop_table('isps')
|
||||
op.drop_index(op.f('ix_devices_organization_id'), table_name='devices')
|
||||
op.drop_table('devices')
|
||||
op.drop_table('users')
|
||||
op.drop_table('organizations')
|
||||
op.drop_table('device_types')
|
||||
op.drop_index(op.f('ix_audit_log_ts'), table_name='audit_log')
|
||||
op.drop_index(op.f('ix_audit_log_entity_type'), table_name='audit_log')
|
||||
op.drop_table('audit_log')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,36 @@
|
||||
"""VRF принадлежит организации префикса (составной FK); имя VRF уникально в организации без учёта регистра
|
||||
|
||||
Revision ID: 0002
|
||||
Revises: 0001
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0002"
|
||||
down_revision = "0001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
dup = bind.execute(sa.text(
|
||||
"SELECT organization_id, lower(name) FROM vrfs GROUP BY 1, 2 HAVING count(*) > 1")).all()
|
||||
if dup:
|
||||
raise RuntimeError(f"Есть VRF с совпадающими (без учёта регистра) именами в одной организации: {dup}")
|
||||
bad = bind.execute(sa.text(
|
||||
"SELECT p.id FROM prefixes p JOIN vrfs v ON v.id = p.vrf_id WHERE v.organization_id <> p.organization_id")).all()
|
||||
if bad:
|
||||
raise RuntimeError(f"Префиксы с VRF чужой организации: {[r[0] for r in bad]}")
|
||||
|
||||
op.create_unique_constraint("uq_vrfs_id_organization_id", "vrfs", ["id", "organization_id"])
|
||||
op.create_foreign_key("fk_prefixes_vrf_org", "prefixes", "vrfs", ["vrf_id", "organization_id"], ["id", "organization_id"])
|
||||
op.drop_constraint("vrfs_organization_id_name_key", "vrfs", type_="unique")
|
||||
op.create_index("uq_vrfs_org_lower_name", "vrfs", ["organization_id", sa.text("lower(name)")], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("uq_vrfs_org_lower_name", table_name="vrfs")
|
||||
op.create_unique_constraint("vrfs_organization_id_name_key", "vrfs", ["organization_id", "name"])
|
||||
op.drop_constraint("fk_prefixes_vrf_org", "prefixes", type_="foreignkey")
|
||||
op.drop_constraint("uq_vrfs_id_organization_id", "vrfs", type_="unique")
|
||||
@@ -0,0 +1,56 @@
|
||||
"""journal search and rotation
|
||||
|
||||
Revision ID: 0003
|
||||
Revises: 0002
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = '0003'
|
||||
down_revision = '0002'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table('app_settings',
|
||||
sa.Column('key', sa.String(length=50), nullable=False),
|
||||
sa.Column('value', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.PrimaryKeyConstraint('key')
|
||||
)
|
||||
op.create_table('clear_attempts',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('ts', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_clear_attempts_user_id'), 'clear_attempts', ['user_id'], unique=False)
|
||||
op.add_column('audit_log', sa.Column('uid', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False))
|
||||
op.add_column('audit_log', sa.Column('message', sa.Text(), server_default='', nullable=False))
|
||||
op.create_index('ix_audit_log_entity_type_action', 'audit_log', ['entity_type', 'action'], unique=False)
|
||||
op.create_unique_constraint("audit_log_uid_key", "audit_log", ["uid"])
|
||||
|
||||
# бэкфилл человекочитаемого сообщения для существующих записей (с согласованием рода глагола)
|
||||
nouns = {"organization": ("Организация", "f"), "vrf": ("VRF", "m"), "prefix": ("Префикс", "m"), "address": ("Адрес", "m"),
|
||||
"device": ("Устройство", "n"), "device_type": ("Тип устройства", "m"), "isp": ("Оператор", "m")}
|
||||
verbs = {"created": ("создан", "создана", "создано"), "updated": ("изменён", "изменена", "изменено"),
|
||||
"deleted": ("удалён", "удалена", "удалено"), "assigned": ("назначен", "назначена", "назначено")}
|
||||
for entity, (noun, gender) in nouns.items():
|
||||
for action, forms in verbs.items():
|
||||
verb = forms[{"m": 0, "f": 1, "n": 2}[gender]]
|
||||
op.get_bind().execute(sa.text(
|
||||
"UPDATE audit_log SET message = :noun || ' ' || entity_label || ' ' || :verb WHERE entity_type = :e AND action = :a"),
|
||||
{"noun": noun, "verb": verb, "e": entity, "a": action})
|
||||
op.execute("""INSERT INTO app_settings (key, value) VALUES ('journal', '{"retention_days": 90, "max_entries": 100000}')""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("audit_log_uid_key", "audit_log", type_="unique")
|
||||
op.drop_index('ix_audit_log_entity_type_action', table_name='audit_log')
|
||||
op.drop_column('audit_log', 'message')
|
||||
op.drop_column('audit_log', 'uid')
|
||||
op.drop_index(op.f('ix_clear_attempts_user_id'), table_name='clear_attempts')
|
||||
op.drop_table('clear_attempts')
|
||||
op.drop_table('app_settings')
|
||||
@@ -0,0 +1,25 @@
|
||||
"""audit client ip and meta
|
||||
|
||||
Revision ID: 0004
|
||||
Revises: 0003
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = '0004'
|
||||
down_revision = '0003'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('audit_log', sa.Column('client_ip', postgresql.INET(), nullable=True))
|
||||
op.add_column('audit_log', sa.Column('meta', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
|
||||
op.create_index(op.f('ix_audit_log_client_ip'), 'audit_log', ['client_ip'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f('ix_audit_log_client_ip'), table_name='audit_log')
|
||||
op.drop_column('audit_log', 'meta')
|
||||
op.drop_column('audit_log', 'client_ip')
|
||||
Reference in new issue
Block a user