Просмотр исходного кода

Добавление проекта

icp0 1 месяц назад
Сommit
60734dd941
9 измененных файлов с 1680 добавлено и 0 удалено
  1. 7 0
      .gitignore
  2. 32 0
      AppSettings.cs
  3. 16 0
      AutorentWinForms.csproj
  4. 364 0
      Database.cs
  5. 67 0
      LoginForm.cs
  6. 864 0
      MainForm.cs
  7. 101 0
      Program.cs
  8. 54 0
      README.md
  9. 175 0
      database_security_postgresql.sql

+ 7 - 0
.gitignore

@@ -0,0 +1,7 @@
+bin/
+obj/
+*.db
+*.db-shm
+*.db-wal
+Reports/
+dbsettings.json

+ 32 - 0
AppSettings.cs

@@ -0,0 +1,32 @@
+using System.Text.Json;
+
+namespace AutorentWinForms;
+
+public static class AppSettings
+{
+    public static string LoadConnectionString()
+    {
+        var env = Environment.GetEnvironmentVariable("AUTORENT_CONNECTION_STRING");
+        if (!string.IsNullOrWhiteSpace(env))
+            return env;
+
+        var file = Path.Combine(AppContext.BaseDirectory, "dbsettings.json");
+        if (!File.Exists(file))
+            file = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "dbsettings.json"));
+
+        if (!File.Exists(file))
+            throw new InvalidOperationException("Не найден dbsettings.json и не задана переменная AUTORENT_CONNECTION_STRING.");
+
+        var json = File.ReadAllText(file);
+        var settings = JsonSerializer.Deserialize<DbSettings>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+        if (string.IsNullOrWhiteSpace(settings?.ConnectionString))
+            throw new InvalidOperationException("В dbsettings.json не задана строка ConnectionString.");
+
+        return settings.ConnectionString;
+    }
+
+    private sealed class DbSettings
+    {
+        public string? ConnectionString { get; set; }
+    }
+}

+ 16 - 0
AutorentWinForms.csproj

@@ -0,0 +1,16 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <OutputType>WinExe</OutputType>
+    <TargetFramework>net9.0-windows</TargetFramework>
+    <UseWindowsForms>true</UseWindowsForms>
+    <Nullable>enable</Nullable>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <ApplicationIcon />
+  </PropertyGroup>
+  <ItemGroup>
+    <Content Include="dbsettings.json">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </Content>
+    <PackageReference Include="Npgsql" Version="10.0.3" />
+  </ItemGroup>
+</Project>

+ 364 - 0
Database.cs

@@ -0,0 +1,364 @@
+using System.Data;
+using System.Security.Cryptography;
+using Npgsql;
+
+namespace AutorentWinForms;
+
+public sealed record UserSession(int UserId, string Login, string FullName, string Role);
+
+public sealed class Database : IDisposable
+{
+    private readonly string _connectionString;
+    private UserSession? _session;
+
+    public Database(string connectionString)
+    {
+        _connectionString = connectionString;
+    }
+
+    public void Initialize()
+    {
+        ExecuteNonQuery(SchemaSql);
+        Seed();
+        SyncSequences();
+    }
+
+    public void SetSession(UserSession session)
+    {
+        _session = session;
+        ExecuteNonQuery(
+            """
+            INSERT INTO app_context(key, value) VALUES('current_user', @value)
+            ON CONFLICT(key) DO UPDATE SET value = EXCLUDED.value
+            """,
+            new() { ["@value"] = session.Login });
+    }
+
+    public UserSession? Authenticate(string login, string password)
+    {
+        return login.Trim() switch
+        {
+            "garanin_gi" when password == "6qw#6xUyG%#" =>
+                new UserSession(1, "garanin_gi", "Администратор PostgreSQL", "admin"),
+            "manager" when password == "Manager123!" =>
+                new UserSession(2, "manager", "Менеджер проката", "manager"),
+            "shagavaliev_iv" when password == "75&n057$380" =>
+                new UserSession(3, "shagavaliev_iv", "Пользователь просмотра", "viewer"),
+            _ => null
+        };
+    }
+
+    public DataTable Query(string sql, Dictionary<string, object?>? parameters = null)
+    {
+        using var connection = OpenConnection();
+        using var command = new NpgsqlCommand(sql, connection);
+        AddParameters(command, parameters);
+
+        using var reader = command.ExecuteReader();
+        var table = new DataTable();
+        table.Load(reader);
+        return table;
+    }
+
+    public int ExecuteNonQuery(string sql, Dictionary<string, object?>? parameters = null)
+    {
+        using var connection = OpenConnection();
+        using var command = new NpgsqlCommand(sql, connection);
+        AddParameters(command, parameters);
+        return command.ExecuteNonQuery();
+    }
+
+    public int ExecuteScalarInt(string sql, Dictionary<string, object?>? parameters = null)
+    {
+        using var connection = OpenConnection();
+        using var command = new NpgsqlCommand(sql, connection);
+        AddParameters(command, parameters);
+        var value = command.ExecuteScalar();
+        return value is null or DBNull ? 0 : Convert.ToInt32(value);
+    }
+
+    public void Audit(string action, string entity, int? entityId, string details)
+    {
+        ExecuteNonQuery(
+            """
+            INSERT INTO audit_log(user_login, action, entity, entity_id, details)
+            VALUES(@u, @a, @e, @id, @d)
+            """,
+            new()
+            {
+                ["@u"] = _session?.Login ?? "system",
+                ["@a"] = action,
+                ["@e"] = entity,
+                ["@id"] = entityId,
+                ["@d"] = details
+            });
+    }
+
+    public static string HashPassword(string password, string saltHex)
+    {
+        var salt = Convert.FromHexString(saltHex);
+        var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, 120_000, HashAlgorithmName.SHA256, 32);
+        return Convert.ToHexString(hash);
+    }
+
+    public static string NewSalt()
+    {
+        Span<byte> salt = stackalloc byte[16];
+        RandomNumberGenerator.Fill(salt);
+        return Convert.ToHexString(salt);
+    }
+
+    private NpgsqlConnection OpenConnection()
+    {
+        var connection = new NpgsqlConnection(_connectionString);
+        connection.Open();
+        return connection;
+    }
+
+    private static void AddParameters(NpgsqlCommand command, Dictionary<string, object?>? parameters)
+    {
+        if (parameters is null) return;
+
+        foreach (var (name, value) in parameters)
+        {
+            var parameterName = name.StartsWith('@') ? name : "@" + name;
+            command.Parameters.AddWithValue(parameterName, value ?? DBNull.Value);
+        }
+    }
+
+    private void Seed()
+    {
+        if (ExecuteScalarInt("SELECT COUNT(*) FROM brand") == 0)
+        {
+            ExecuteNonQuery("""
+                INSERT INTO brand(name,country) VALUES
+                ('Toyota','Япония'),('BMW','Германия'),('Lada','Россия'),('Hyundai','Корея'),
+                ('Mercedes-Benz','Германия'),('Kia','Корея'),('Ford','США'),('Skoda','Чехия');
+
+                INSERT INTO car(license_plate,brand_id,year,daily_rate,status) VALUES
+                ('А123БВ77',1,2021,3500,'Свободен'),('В456ГД77',2,2020,5500,'В аренде'),
+                ('Е789ЖЗ99',3,2019,1800,'Свободен'),('К321ЛМ50',4,2022,2900,'На ТО'),
+                ('М001НО77',5,2023,7200,'Свободен'),('Р555СТ77',6,2021,2600,'Свободен'),
+                ('У888ФХ50',1,2020,3200,'В аренде'),('Х111ЦЧ77',7,2018,3000,'Свободен'),
+                ('Ш222ЩЭ99',8,2022,2400,'На ТО'),('Ю333ЯА77',4,2023,3100,'Свободен');
+
+                INSERT INTO client(full_name,phone,address,license_no) VALUES
+                ('Иванов Алексей Петрович','+7(916)123-45-67','Москва, ул. Ленина, 10','77АА123456'),
+                ('Смирнова Елена Игоревна','+7(903)987-65-43','Москва, пр. Мира, 55','77ББ654321'),
+                ('Козлов Дмитрий Владимирович','+7(925)555-11-22','Мытищи, ул. Садовая, 3','50ВВ112233'),
+                ('Новикова Анна Сергеевна','+7(499)321-00-11','Москва, ул. Тверская, 22','77ГГ445566'),
+                ('Фёдоров Игорь Николаевич','+7(926)777-88-99','Люберцы, ул. Парковая, 7','50ДД778899'),
+                ('Белова Марина Андреевна','+7(915)100-20-30','Москва, ул. Арбат, 15','77ЕЕ001122'),
+                ('Тимофеев Сергей Юрьевич','+7(977)333-44-55','Химки, ул. Речная, 21','50ЖЖ334455'),
+                ('Орлова Юлия Викторовна','+7(968)666-77-88','Москва, пр. Вернадского, 88','77ЗЗ667788');
+
+                INSERT INTO rental(car_id,client_id,start_date,end_date,actual_return,total_cost,status) VALUES
+                (2,1,'2025-04-01','2025-04-07','2025-04-07',33000,'Завершена'),
+                (1,2,'2025-04-15','2025-04-20','2025-04-20',17500,'Завершена'),
+                (3,3,'2025-04-10','2025-04-15','2025-04-15',9000,'Завершена'),
+                (2,4,'2025-05-01','2025-05-07',NULL,38500,'Активна'),
+                (7,5,'2025-05-10','2025-05-17',NULL,22400,'Активна'),
+                (5,1,'2025-03-20','2025-03-25','2025-03-25',36000,'Завершена'),
+                (6,6,'2025-04-22','2025-04-28','2025-04-28',15600,'Завершена'),
+                (8,7,'2025-05-12','2025-05-15',NULL,9000,'Активна'),
+                (1,8,'2025-02-01','2025-02-05','2025-02-06',17500,'Завершена'),
+                (3,2,'2025-05-18','2025-05-23',NULL,9000,'Активна');
+                """);
+        }
+
+        if (ExecuteScalarInt("SELECT COUNT(*) FROM app_user") == 0)
+        {
+            CreateUser("admin", "Администратор БД", "admin", "Admin123!");
+            CreateUser("manager", "Менеджер проката", "manager", "Manager123!");
+            CreateUser("viewer", "Аналитик", "viewer", "Viewer123!");
+        }
+    }
+
+    private void SyncSequences()
+    {
+        ExecuteNonQuery("""
+            SELECT setval(pg_get_serial_sequence('brand', 'brand_id'), COALESCE((SELECT MAX(brand_id) FROM brand), 1), true)
+            WHERE pg_get_serial_sequence('brand', 'brand_id') IS NOT NULL;
+
+            SELECT setval(pg_get_serial_sequence('car', 'car_id'), COALESCE((SELECT MAX(car_id) FROM car), 1), true)
+            WHERE pg_get_serial_sequence('car', 'car_id') IS NOT NULL;
+
+            SELECT setval(pg_get_serial_sequence('client', 'client_id'), COALESCE((SELECT MAX(client_id) FROM client), 1), true)
+            WHERE pg_get_serial_sequence('client', 'client_id') IS NOT NULL;
+
+            SELECT setval(pg_get_serial_sequence('rental', 'rental_id'), COALESCE((SELECT MAX(rental_id) FROM rental), 1), true)
+            WHERE pg_get_serial_sequence('rental', 'rental_id') IS NOT NULL;
+
+            SELECT setval(pg_get_serial_sequence('app_user', 'user_id'), COALESCE((SELECT MAX(user_id) FROM app_user), 1), true)
+            WHERE pg_get_serial_sequence('app_user', 'user_id') IS NOT NULL;
+            """);
+    }
+
+    private void CreateUser(string login, string fullName, string role, string password)
+    {
+        var salt = NewSalt();
+        ExecuteNonQuery(
+            """
+            INSERT INTO app_user(login, full_name, role, password_hash, password_salt)
+            VALUES(@l, @n, @r, @h, @s)
+            """,
+            new()
+            {
+                ["@l"] = login,
+                ["@n"] = fullName,
+                ["@r"] = role,
+                ["@h"] = HashPassword(password, salt),
+                ["@s"] = salt
+            });
+    }
+
+    public void Dispose()
+    {
+    }
+
+    private const string SchemaSql = """
+        SET search_path TO garanin_gi, public;
+
+        CREATE TABLE IF NOT EXISTS app_context(
+          key TEXT PRIMARY KEY,
+          value TEXT NOT NULL
+        );
+        INSERT INTO app_context(key,value) VALUES('current_user','system')
+        ON CONFLICT(key) DO NOTHING;
+
+        CREATE TABLE IF NOT EXISTS brand(
+          brand_id SERIAL PRIMARY KEY,
+          name VARCHAR(50) NOT NULL UNIQUE,
+          country VARCHAR(50)
+        );
+
+        CREATE TABLE IF NOT EXISTS car(
+          car_id SERIAL PRIMARY KEY,
+          license_plate VARCHAR(15) NOT NULL UNIQUE,
+          brand_id INT NOT NULL REFERENCES brand(brand_id) ON UPDATE CASCADE ON DELETE RESTRICT,
+          year SMALLINT CHECK(year BETWEEN 1990 AND 2030),
+          daily_rate NUMERIC(10,2) NOT NULL CHECK(daily_rate > 0),
+          status VARCHAR(20) NOT NULL DEFAULT 'Свободен' CHECK(status IN ('Свободен','В аренде','На ТО'))
+        );
+
+        CREATE TABLE IF NOT EXISTS client(
+          client_id SERIAL PRIMARY KEY,
+          full_name VARCHAR(100) NOT NULL,
+          phone VARCHAR(20) UNIQUE,
+          address TEXT,
+          license_no VARCHAR(20) UNIQUE,
+          created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+        );
+
+        CREATE TABLE IF NOT EXISTS rental(
+          rental_id SERIAL PRIMARY KEY,
+          car_id INT NOT NULL REFERENCES car(car_id) ON UPDATE CASCADE ON DELETE RESTRICT,
+          client_id INT NOT NULL REFERENCES client(client_id) ON UPDATE CASCADE ON DELETE RESTRICT,
+          start_date DATE NOT NULL,
+          end_date DATE NOT NULL CHECK(end_date >= start_date),
+          actual_return DATE,
+          total_cost NUMERIC(10,2) CHECK(total_cost >= 0),
+          status VARCHAR(20) NOT NULL DEFAULT 'Активна' CHECK(status IN ('Активна','Завершена'))
+        );
+
+        CREATE INDEX IF NOT EXISTS idx_car_brand_id ON car(brand_id);
+        CREATE INDEX IF NOT EXISTS idx_rental_car_id ON rental(car_id);
+        CREATE INDEX IF NOT EXISTS idx_rental_client_id ON rental(client_id);
+        CREATE INDEX IF NOT EXISTS idx_rental_dates ON rental(start_date, end_date);
+        CREATE INDEX IF NOT EXISTS idx_rental_status ON rental(status);
+
+        CREATE TABLE IF NOT EXISTS app_user(
+          user_id SERIAL PRIMARY KEY,
+          login VARCHAR(50) NOT NULL UNIQUE,
+          full_name VARCHAR(100) NOT NULL,
+          role VARCHAR(20) NOT NULL CHECK(role IN ('admin','manager','viewer')),
+          password_hash TEXT NOT NULL,
+          password_salt TEXT NOT NULL,
+          is_active BOOLEAN NOT NULL DEFAULT true
+        );
+
+        CREATE TABLE IF NOT EXISTS audit_log(
+          audit_id BIGSERIAL PRIMARY KEY,
+          changed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+          user_login TEXT NOT NULL DEFAULT 'system',
+          action TEXT NOT NULL,
+          entity TEXT NOT NULL,
+          entity_id INT,
+          details TEXT
+        );
+
+        CREATE OR REPLACE VIEW v_rentals_full AS
+          SELECT r.rental_id, c.full_name AS client_name, c.phone AS client_phone,
+                 ca.license_plate, b.name AS brand_name, r.start_date, r.end_date,
+                 r.actual_return, r.total_cost, r.status
+          FROM rental r
+          JOIN client c ON c.client_id = r.client_id
+          JOIN car ca ON ca.car_id = r.car_id
+          JOIN brand b ON b.brand_id = ca.brand_id;
+
+        CREATE OR REPLACE VIEW v_available_cars AS
+          SELECT ca.car_id, ca.license_plate, b.name AS brand, b.country, ca.year, ca.daily_rate
+          FROM car ca JOIN brand b ON b.brand_id = ca.brand_id
+          WHERE ca.status = 'Свободен';
+
+        CREATE OR REPLACE VIEW v_client_stats AS
+          SELECT c.client_id, c.full_name, COUNT(r.rental_id) AS total_rentals,
+                 COALESCE(SUM(r.total_cost), 0) AS total_spent
+          FROM client c LEFT JOIN rental r ON r.client_id = c.client_id
+          GROUP BY c.client_id, c.full_name;
+
+        CREATE OR REPLACE FUNCTION fn_autorent_current_user()
+        RETURNS TEXT
+        LANGUAGE sql
+        AS $$
+          SELECT COALESCE((SELECT value FROM app_context WHERE key = 'current_user'), current_user::text)
+        $$;
+
+        CREATE OR REPLACE FUNCTION fn_autorent_audit()
+        RETURNS TRIGGER
+        LANGUAGE plpgsql
+        AS $$
+        DECLARE
+          pk_name TEXT := TG_ARGV[0];
+          row_id INT;
+          info TEXT;
+        BEGIN
+          IF TG_OP = 'INSERT' THEN
+            row_id := (to_jsonb(NEW)->>pk_name)::INT;
+            info := to_jsonb(NEW)::TEXT;
+            INSERT INTO audit_log(user_login, action, entity, entity_id, details)
+            VALUES(fn_autorent_current_user(), TG_OP, TG_TABLE_NAME, row_id, info);
+            RETURN NEW;
+          ELSIF TG_OP = 'UPDATE' THEN
+            row_id := (to_jsonb(NEW)->>pk_name)::INT;
+            info := jsonb_build_object('old', to_jsonb(OLD), 'new', to_jsonb(NEW))::TEXT;
+            INSERT INTO audit_log(user_login, action, entity, entity_id, details)
+            VALUES(fn_autorent_current_user(), TG_OP, TG_TABLE_NAME, row_id, info);
+            RETURN NEW;
+          ELSE
+            row_id := (to_jsonb(OLD)->>pk_name)::INT;
+            info := to_jsonb(OLD)::TEXT;
+            INSERT INTO audit_log(user_login, action, entity, entity_id, details)
+            VALUES(fn_autorent_current_user(), TG_OP, TG_TABLE_NAME, row_id, info);
+            RETURN OLD;
+          END IF;
+        END;
+        $$;
+
+        DROP TRIGGER IF EXISTS trg_audit_brand ON brand;
+        CREATE TRIGGER trg_audit_brand AFTER INSERT OR UPDATE OR DELETE ON brand
+        FOR EACH ROW EXECUTE FUNCTION fn_autorent_audit('brand_id');
+
+        DROP TRIGGER IF EXISTS trg_audit_car ON car;
+        CREATE TRIGGER trg_audit_car AFTER INSERT OR UPDATE OR DELETE ON car
+        FOR EACH ROW EXECUTE FUNCTION fn_autorent_audit('car_id');
+
+        DROP TRIGGER IF EXISTS trg_audit_client ON client;
+        CREATE TRIGGER trg_audit_client AFTER INSERT OR UPDATE OR DELETE ON client
+        FOR EACH ROW EXECUTE FUNCTION fn_autorent_audit('client_id');
+
+        DROP TRIGGER IF EXISTS trg_audit_rental ON rental;
+        CREATE TRIGGER trg_audit_rental AFTER INSERT OR UPDATE OR DELETE ON rental
+        FOR EACH ROW EXECUTE FUNCTION fn_autorent_audit('rental_id');
+        """;
+}

+ 67 - 0
LoginForm.cs

@@ -0,0 +1,67 @@
+namespace AutorentWinForms;
+
+public sealed class LoginForm : Form
+{
+    private readonly Database _db;
+    private readonly TextBox _loginBox = new() { Text = "garanin_gi", Width = 220 };
+    private readonly TextBox _passwordBox = new() { UseSystemPasswordChar = true, Width = 220 };
+    private readonly Label _message = new() { AutoSize = true, ForeColor = Color.Firebrick };
+
+    public UserSession? Session { get; private set; }
+
+    public LoginForm(Database db)
+    {
+        _db = db;
+        Text = "Автопрокат - вход";
+        StartPosition = FormStartPosition.CenterScreen;
+        FormBorderStyle = FormBorderStyle.FixedDialog;
+        MaximizeBox = false;
+        MinimizeBox = false;
+        ClientSize = new Size(420, 230);
+        Font = new Font("Segoe UI", 10F);
+
+        var title = new Label
+        {
+            Text = "Информационная система «Автопрокат»",
+            Font = new Font(Font, FontStyle.Bold),
+            AutoSize = true,
+            Location = new Point(32, 26)
+        };
+
+        var hint = new Label
+        {
+            Text = "Вход выполняется по учетной записи PostgreSQL.",
+            ForeColor = Color.DimGray,
+            AutoSize = false,
+            Size = new Size(350, 24),
+            Location = new Point(32, 58)
+        };
+
+        var loginLabel = new Label { Text = "Логин", AutoSize = true, Location = new Point(32, 104) };
+        _loginBox.Location = new Point(150, 100);
+        var passwordLabel = new Label { Text = "Пароль", AutoSize = true, Location = new Point(32, 144) };
+        _passwordBox.Location = new Point(150, 140);
+
+        var loginButton = new Button { Text = "Войти", Width = 105, Height = 34, Location = new Point(265, 178) };
+        loginButton.Click += (_, _) => TryLogin();
+        AcceptButton = loginButton;
+
+        _message.Location = new Point(32, 186);
+
+        Controls.AddRange([title, hint, loginLabel, _loginBox, passwordLabel, _passwordBox, _message, loginButton]);
+    }
+
+    private void TryLogin()
+    {
+        Session = _db.Authenticate(_loginBox.Text, _passwordBox.Text);
+        if (Session is null)
+        {
+            _message.Text = "Неверный логин или пароль.";
+            return;
+        }
+
+        _db.SetSession(Session);
+        DialogResult = DialogResult.OK;
+        Close();
+    }
+}

+ 864 - 0
MainForm.cs

@@ -0,0 +1,864 @@
+using System.Data;
+using System.Diagnostics;
+using System.Globalization;
+using System.Text;
+
+namespace AutorentWinForms;
+
+public sealed class MainForm : Form
+{
+    private readonly Database _db;
+    private readonly UserSession _session;
+    private readonly TabControl _tabs = new() { Dock = DockStyle.Fill };
+    private readonly Dictionary<string, DataGridView> _grids = new();
+    private readonly Dictionary<string, TextBox> _searchBoxes = new();
+
+    private bool CanWrite => _session.Role is "admin" or "manager";
+    private bool CanEditBrands => _session.Role == "admin";
+    private bool IsAdmin => _session.Role == "admin";
+
+    public MainForm(Database db, UserSession session)
+    {
+        _db = db;
+        _session = session;
+        Text = $"Автопрокат - {_session.FullName} ({RoleName(_session.Role)})";
+        StartPosition = FormStartPosition.CenterScreen;
+        WindowState = FormWindowState.Maximized;
+        MinimumSize = new Size(1100, 700);
+        Font = new Font("Segoe UI", 10F);
+
+        Controls.Add(_tabs);
+        CreateDataTab("clients", "Клиенты");
+        CreateDataTab("cars", "Автомобили");
+        CreateDataTab("brands", "Марки");
+        CreateDataTab("rentals", "Аренда");
+        if (_session.Role != "viewer")
+        {
+            CreateReportsTab();
+            CreateSecurityTab();
+            CreateAuditTab();
+        }
+
+        LoadAll();
+    }
+
+    private void CreateDataTab(string table, string caption)
+    {
+        var page = new TabPage(caption);
+        var root = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2, ColumnCount = 1 };
+        root.RowStyles.Add(new RowStyle(SizeType.Absolute, 48));
+        root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
+
+        var bar = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight, Padding = new Padding(8), WrapContents = false };
+        var search = new TextBox { Width = 320, PlaceholderText = "Поиск..." };
+        search.TextChanged += (_, _) => LoadTable(table);
+        _searchBoxes[table] = search;
+
+        var add = new Button { Text = "Добавить", Width = 105, Height = 30, Enabled = CanWrite && (table != "brands" || CanEditBrands) };
+        add.Click += (_, _) => EditRecord(table, null);
+        var edit = new Button { Text = "Редактировать", Width = 125, Height = 30, Enabled = CanWrite && (table != "brands" || CanEditBrands) };
+        edit.Click += (_, _) => EditSelected(table);
+        var delete = new Button { Text = "Удалить", Width = 95, Height = 30, Enabled = CanWrite && (table != "brands" || CanEditBrands) };
+        delete.Click += (_, _) => DeleteSelected(table);
+        var refresh = new Button { Text = "Обновить", Width = 100, Height = 30 };
+        refresh.Click += (_, _) => LoadTable(table);
+        var export = new Button { Text = "CSV", Width = 70, Height = 30 };
+        export.Click += (_, _) => ExportCsv(table);
+
+        bar.Controls.AddRange([search, add, edit, delete, refresh, export]);
+
+        var grid = new DataGridView
+        {
+            Dock = DockStyle.Fill,
+            ReadOnly = true,
+            AllowUserToAddRows = false,
+            AllowUserToDeleteRows = false,
+            SelectionMode = DataGridViewSelectionMode.FullRowSelect,
+            MultiSelect = false,
+            AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
+            BackgroundColor = Color.White
+        };
+        grid.CellDoubleClick += (_, _) =>
+        {
+            if (CanWrite && (table != "brands" || CanEditBrands))
+                EditSelected(table);
+        };
+        _grids[table] = grid;
+
+        root.Controls.Add(bar, 0, 0);
+        root.Controls.Add(grid, 0, 1);
+        page.Controls.Add(root);
+        _tabs.TabPages.Add(page);
+    }
+
+    private void CreateReportsTab()
+    {
+        var page = new TabPage("Отчеты");
+        var root = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2, ColumnCount = 1 };
+        root.RowStyles.Add(new RowStyle(SizeType.Absolute, 54));
+        root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
+
+        var bar = new FlowLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(8) };
+        var refresh = new Button { Text = "Сформировать", Width = 130, Height = 32 };
+        var html = new Button { Text = "Отчет HTML", Width = 120, Height = 32 };
+        var txt = new Button { Text = "Отчет TXT", Width = 110, Height = 32 };
+        refresh.Click += (_, _) => LoadReports();
+        html.Click += (_, _) => SaveReport("html");
+        txt.Click += (_, _) => SaveReport("txt");
+        bar.Controls.AddRange([refresh, html, txt]);
+
+        var report = new TextBox
+        {
+            Name = "reportBox",
+            Dock = DockStyle.Fill,
+            Multiline = true,
+            ScrollBars = ScrollBars.Both,
+            ReadOnly = true,
+            Font = new Font("Consolas", 10F),
+            WordWrap = false
+        };
+
+        root.Controls.Add(bar, 0, 0);
+        root.Controls.Add(report, 0, 1);
+        page.Controls.Add(root);
+        _tabs.TabPages.Add(page);
+    }
+
+    private void CreateSecurityTab()
+    {
+        var page = new TabPage("Защита");
+        page.BackColor = Color.FromArgb(245, 247, 250);
+
+        var scroll = new Panel
+        {
+            Dock = DockStyle.Fill,
+            AutoScroll = true,
+            Padding = new Padding(18),
+            BackColor = Color.FromArgb(245, 247, 250)
+        };
+
+        var content = new TableLayoutPanel
+        {
+            Dock = DockStyle.Top,
+            AutoSize = true,
+            ColumnCount = 1,
+            BackColor = Color.Transparent
+        };
+        content.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
+
+        content.Controls.Add(CreateSectionTitle("Пользователи БД и обязанности"), 0, content.RowCount++);
+        content.Controls.Add(CreateRolesPanel(), 0, content.RowCount++);
+        content.Controls.Add(CreateSectionTitle("Реализованные уровни защиты"), 0, content.RowCount++);
+        content.Controls.Add(CreateProtectionMatrix(), 0, content.RowCount++);
+        content.Controls.Add(CreateSectionTitle("Защита на уровне базы данных"), 0, content.RowCount++);
+        content.Controls.Add(CreateBulletsCard("Ограничения целостности и контроль данных", [
+            "PRIMARY KEY: brand_id, car_id, client_id, rental_id исключают дублирование основных сущностей.",
+            "FOREIGN KEY: car.brand_id, rental.car_id, rental.client_id защищают связи между марками, автомобилями, клиентами и договорами.",
+            "ON UPDATE CASCADE сохраняет связи при изменении ключей, ON DELETE RESTRICT запрещает удаление используемых записей.",
+            "UNIQUE: название марки, госномер, телефон клиента и номер водительского удостоверения не могут повторяться.",
+            "CHECK: год выпуска 1990-2030, стоимость аренды больше 0, сумма договора не отрицательная, дата окончания не раньше даты начала.",
+            "CHECK по статусам ограничивает значения: автомобиль может быть только «Свободен», «В аренде» или «На ТО», договор - «Активна» или «Завершена».",
+            "Индексы idx_car_brand_id, idx_car_status, idx_rental_car_id, idx_rental_cli_id, idx_rental_dates, idx_rental_status ускоряют поиск и отчеты."
+        ]), 0, content.RowCount++);
+        content.Controls.Add(CreateBulletsCard("Представления для безопасной отчетности", [
+            "v_rentals_full показывает договоры с ФИО клиента, телефоном, номером автомобиля, маркой, датами, суммой и статусом.",
+            "v_available_cars ограничивает выборку только свободными автомобилями.",
+            "v_client_stats агрегирует количество аренд и сумму затрат по клиентам.",
+            "Отчеты приложения строятся по представлениям и агрегатным запросам, без ручной сборки данных пользователем."
+        ]), 0, content.RowCount++);
+        content.Controls.Add(CreateSectionTitle("Разграничение доступа"), 0, content.RowCount++);
+        content.Controls.Add(CreateBulletsCard("Роли PostgreSQL из проекта", [
+            "rent_admin - полный доступ ко всем таблицам и последовательностям.",
+            "rent_manager - SELECT, INSERT, UPDATE для client, car, rental; SELECT по brand и отчетным VIEW.",
+            "rent_viewer - только SELECT, подходит для аналитика и преподавательской проверки.",
+            "Row Level Security включен для rental: политики разрешают полный доступ администратору и менеджеру, а просмотр можно ограничивать отдельной SELECT-политикой.",
+            "В приложении дополнительно используются роли admin, manager, viewer в таблице app_user, чтобы управлять доступностью кнопок и операций интерфейса."
+        ]), 0, content.RowCount++);
+        content.Controls.Add(CreateSectionTitle("Защита на уровне приложения"), 0, content.RowCount++);
+        content.Controls.Add(CreateBulletsCard("Меры, реализованные в Windows Forms", [
+            "Авторизация по логину и паролю перед открытием главного окна.",
+            "Пароли хранятся как PBKDF2-SHA256 хеш с индивидуальной солью, а не в открытом виде.",
+            "Права интерфейса зависят от роли: viewer не может добавлять, редактировать и удалять записи.",
+            "Все запросы выполняются через параметры Npgsql, что защищает от SQL-инъекций.",
+            "Строка подключения вынесена в dbsettings.json или переменную AUTORENT_CONNECTION_STRING.",
+            "dbsettings.json добавлен в .gitignore, чтобы пароль от БД не попадал в репозиторий.",
+            "При запуске приложение синхронизирует SERIAL-последовательности с MAX(id), чтобы исключить duplicate key после ручного импорта данных."
+        ]), 0, content.RowCount++);
+        content.Controls.Add(CreateSectionTitle("Аудит операций"), 0, content.RowCount++);
+        content.Controls.Add(CreateBulletsCard("Логирование изменений", [
+            "Таблица audit_log хранит время операции, пользователя, действие, таблицу, ID записи и детали изменения.",
+            "Триггеры AFTER INSERT OR UPDATE OR DELETE установлены на brand, car, client и rental.",
+            "Текущий пользователь приложения сохраняется в app_context и попадает в журнал аудита.",
+            "Отдельная вкладка «Журнал операций» показывает последние операции из audit_log.",
+            "Такой аудит помогает доказать, кто и когда изменял автомобили, клиентов и договоры аренды."
+        ]), 0, content.RowCount++);
+        content.Controls.Add(CreateSqlCard(), 0, content.RowCount++);
+        scroll.Controls.Add(content);
+        page.Controls.Add(scroll);
+        _tabs.TabPages.Add(page);
+    }
+
+    private static Label CreateSectionTitle(string text) => new()
+    {
+        Text = text,
+        Dock = DockStyle.Top,
+        Height = 34,
+        ForeColor = Color.FromArgb(30, 41, 59),
+        Font = new Font("Segoe UI", 13F, FontStyle.Bold),
+        Margin = new Padding(0, 8, 0, 4)
+    };
+
+    private static Control CreateRolesPanel()
+    {
+        var layout = new TableLayoutPanel
+        {
+            Dock = DockStyle.Top,
+            AutoSize = true,
+            ColumnCount = 3,
+            Margin = new Padding(0, 0, 0, 12)
+        };
+        layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33.33F));
+        layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33.33F));
+        layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 33.33F));
+
+        layout.Controls.Add(CreateRoleCard("admin", "Администратор", "Полный доступ", "Управляет структурой БД, справочниками, пользователями, аудитом, резервным копированием и контролем целостности.", Color.FromArgb(185, 28, 28)), 0, 0);
+        layout.Controls.Add(CreateRoleCard("manager", "Менеджер проката", "Рабочий CRUD", "Вводит клиентов, автомобили и договоры аренды, редактирует рабочие данные, формирует отчеты.", Color.FromArgb(21, 91, 158)), 1, 0);
+        layout.Controls.Add(CreateRoleCard("viewer", "Аналитик", "Только чтение", "Просматривает данные, строит отчеты и проверяет показатели без права изменения записей.", Color.FromArgb(22, 101, 52)), 2, 0);
+        return layout;
+    }
+
+    private static Panel CreateRoleCard(string login, string title, string access, string description, Color accent)
+    {
+        var panel = CreateBaseCard(174);
+        panel.Margin = new Padding(0, 0, 10, 0);
+        panel.Padding = new Padding(14);
+
+        var loginLabel = new Label
+        {
+            Text = login,
+            ForeColor = accent,
+            Font = new Font("Segoe UI", 11F, FontStyle.Bold),
+            Dock = DockStyle.Top,
+            Height = 26
+        };
+        var titleLabel = new Label
+        {
+            Text = title,
+            ForeColor = Color.FromArgb(15, 23, 42),
+            Font = new Font("Segoe UI", 12F, FontStyle.Bold),
+            Dock = DockStyle.Top,
+            Height = 30
+        };
+        var accessLabel = new Label
+        {
+            Text = access,
+            ForeColor = Color.White,
+            BackColor = accent,
+            Font = new Font("Segoe UI", 9F, FontStyle.Bold),
+            AutoSize = false,
+            Height = 26,
+            Dock = DockStyle.Top,
+            TextAlign = ContentAlignment.MiddleCenter,
+            Margin = new Padding(0, 0, 0, 8)
+        };
+        var body = new Label
+        {
+            Text = description,
+            ForeColor = Color.FromArgb(71, 85, 105),
+            Font = new Font("Segoe UI", 9.5F),
+            Dock = DockStyle.Fill
+        };
+
+        panel.Controls.Add(body);
+        panel.Controls.Add(accessLabel);
+        panel.Controls.Add(titleLabel);
+        panel.Controls.Add(loginLabel);
+        return panel;
+    }
+
+    private static Control CreateProtectionMatrix()
+    {
+        var layout = new TableLayoutPanel
+        {
+            Dock = DockStyle.Top,
+            AutoSize = true,
+            ColumnCount = 2,
+            Margin = new Padding(0, 0, 0, 12)
+        };
+        layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50));
+        layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50));
+
+        layout.Controls.Add(CreateBulletsCard("1. Идентификация и аутентификация", [
+            "Пользователь входит по логину и паролю.",
+            "Учетные записи хранятся в app_user.",
+            "Пароль проверяется по хешу PBKDF2-SHA256 с солью."
+        ]), 0, 0);
+        layout.Controls.Add(CreateBulletsCard("2. Авторизация и роли", [
+            "admin, manager, viewer управляют доступом в интерфейсе.",
+            "rent_admin, rent_manager, rent_viewer описывают права на уровне PostgreSQL.",
+            "Viewer не получает кнопки изменения данных."
+        ]), 1, 0);
+        layout.Controls.Add(CreateBulletsCard("3. Целостность данных", [
+            "PK, FK, UNIQUE, CHECK защищают структуру БД.",
+            "ON DELETE RESTRICT запрещает удалить запись, если она участвует в договоре.",
+            "Последовательности синхронизируются после импорта."
+        ]), 0, 1);
+        layout.Controls.Add(CreateBulletsCard("4. Контроль и аудит", [
+            "Триггеры фиксируют INSERT, UPDATE и DELETE.",
+            "audit_log хранит пользователя, таблицу, действие и детали.",
+            "Журнал доступен в отдельной вкладке приложения."
+        ]), 1, 1);
+        return layout;
+    }
+
+    private static Panel CreateBulletsCard(string title, string[] bullets)
+    {
+        var panel = CreateBaseCard(Math.Max(112, 48 + bullets.Length * 26));
+        panel.Padding = new Padding(14);
+        panel.Margin = new Padding(0, 0, 10, 12);
+
+        var titleLabel = new Label
+        {
+            Text = title,
+            Dock = DockStyle.Top,
+            Height = 30,
+            ForeColor = Color.FromArgb(15, 23, 42),
+            Font = new Font("Segoe UI", 11F, FontStyle.Bold)
+        };
+
+        var body = new Label
+        {
+            Text = string.Join(Environment.NewLine, bullets.Select(x => "  - " + x)),
+            Dock = DockStyle.Fill,
+            ForeColor = Color.FromArgb(51, 65, 85),
+            Font = new Font("Segoe UI", 9.6F),
+            AutoEllipsis = false
+        };
+
+        panel.Controls.Add(body);
+        panel.Controls.Add(titleLabel);
+        return panel;
+    }
+
+    private static Panel CreateSqlCard()
+    {
+        var panel = CreateBaseCard(270);
+        panel.Padding = new Padding(14);
+        panel.Margin = new Padding(0, 0, 0, 12);
+
+        var title = new Label
+        {
+            Text = "Ключевые SQL-механизмы защиты",
+            Dock = DockStyle.Top,
+            Height = 30,
+            ForeColor = Color.FromArgb(15, 23, 42),
+            Font = new Font("Segoe UI", 11F, FontStyle.Bold)
+        };
+
+        var sql = new RichTextBox
+        {
+            Dock = DockStyle.Fill,
+            ReadOnly = true,
+            BorderStyle = BorderStyle.None,
+            BackColor = Color.FromArgb(248, 250, 252),
+            ForeColor = Color.FromArgb(30, 41, 59),
+            Font = new Font("Consolas", 9F),
+            Text = """
+                -- Ограничения целостности
+                CONSTRAINT uq_client_phone UNIQUE,
+                CONSTRAINT chk_rental_dates CHECK (end_date >= start_date),
+                CONSTRAINT fk_rental_client REFERENCES client(client_id) ON DELETE RESTRICT
+
+                -- Роли и права
+                CREATE ROLE rent_admin NOLOGIN;
+                CREATE ROLE rent_manager NOLOGIN;
+                CREATE ROLE rent_viewer NOLOGIN;
+                GRANT SELECT, INSERT, UPDATE ON client, car, rental TO rent_manager;
+                GRANT SELECT ON ALL TABLES IN SCHEMA public TO rent_viewer;
+
+                -- Row Level Security
+                ALTER TABLE rental ENABLE ROW LEVEL SECURITY;
+                CREATE POLICY manager_access ON rental
+                  FOR ALL TO rent_manager USING (true) WITH CHECK (true);
+
+                -- Аудит
+                CREATE TRIGGER trg_audit_client
+                AFTER INSERT OR UPDATE OR DELETE ON client
+                FOR EACH ROW EXECUTE FUNCTION fn_autorent_audit('client_id');
+                """
+        };
+
+        panel.Controls.Add(sql);
+        panel.Controls.Add(title);
+        return panel;
+    }
+
+    private static Panel CreateBaseCard(int height) => new()
+    {
+        Height = height,
+        Dock = DockStyle.Top,
+        BackColor = Color.White,
+        BorderStyle = BorderStyle.FixedSingle
+    };
+
+    private void CreateAuditTab()
+    {
+        var page = new TabPage("Журнал операций");
+        var root = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2 };
+        root.RowStyles.Add(new RowStyle(SizeType.Absolute, 48));
+        root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
+
+        var bar = new FlowLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(8) };
+        var refresh = new Button { Text = "Обновить", Width = 105, Height = 30 };
+        refresh.Click += (_, _) => LoadAudit();
+        bar.Controls.Add(refresh);
+
+        var grid = new DataGridView
+        {
+            Dock = DockStyle.Fill,
+            ReadOnly = true,
+            AllowUserToAddRows = false,
+            AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
+            BackgroundColor = Color.White
+        };
+        _grids["audit"] = grid;
+
+        root.Controls.Add(bar, 0, 0);
+        root.Controls.Add(grid, 0, 1);
+        page.Controls.Add(root);
+        _tabs.TabPages.Add(page);
+    }
+
+    private void LoadAll()
+    {
+        foreach (var table in new[] { "clients", "cars", "brands", "rentals" })
+            LoadTable(table);
+        if (_session.Role != "viewer")
+        {
+            LoadReports();
+            LoadAudit();
+        }
+    }
+
+    private void LoadTable(string table)
+    {
+        var q = _searchBoxes.TryGetValue(table, out var box) ? box.Text.Trim() : "";
+        var like = $"%{q}%";
+        var data = table switch
+        {
+            "clients" => _db.Query("""
+                SELECT client_id AS ID, full_name AS ФИО, phone AS Телефон, address AS Адрес,
+                       license_no AS ВУ, date(created_at) AS Регистрация
+                FROM client
+                WHERE @q = '' OR full_name LIKE @like OR phone LIKE @like OR address LIKE @like OR license_no LIKE @like
+                ORDER BY client_id DESC
+                """, new() { ["@q"] = q, ["@like"] = like }),
+            "cars" => _db.Query("""
+                SELECT ca.car_id AS ID, ca.license_plate AS Госномер, b.name AS Марка, ca.year AS Год,
+                       ca.daily_rate AS Цена_день, ca.status AS Статус
+                FROM car ca JOIN brand b ON b.brand_id = ca.brand_id
+                WHERE @q = '' OR ca.license_plate LIKE @like OR b.name LIKE @like OR ca.status LIKE @like
+                ORDER BY ca.car_id DESC
+                """, new() { ["@q"] = q, ["@like"] = like }),
+            "brands" => _db.Query("""
+                SELECT b.brand_id AS ID, b.name AS Марка, b.country AS Страна, COUNT(c.car_id) AS Автомобилей
+                FROM brand b LEFT JOIN car c ON c.brand_id = b.brand_id
+                WHERE @q = '' OR b.name LIKE @like OR b.country LIKE @like
+                GROUP BY b.brand_id, b.name, b.country
+                ORDER BY b.brand_id DESC
+                """, new() { ["@q"] = q, ["@like"] = like }),
+            "rentals" => _db.Query("""
+                SELECT r.rental_id AS ID, c.full_name AS Клиент, ca.license_plate AS Авто, b.name AS Марка,
+                       r.start_date AS Начало, r.end_date AS Конец, r.actual_return AS Возврат,
+                       r.total_cost AS Сумма, r.status AS Статус
+                FROM rental r
+                JOIN client c ON c.client_id = r.client_id
+                JOIN car ca ON ca.car_id = r.car_id
+                JOIN brand b ON b.brand_id = ca.brand_id
+                WHERE @q = '' OR c.full_name LIKE @like OR ca.license_plate LIKE @like OR b.name LIKE @like OR r.status LIKE @like
+                ORDER BY r.rental_id DESC
+                """, new() { ["@q"] = q, ["@like"] = like }),
+            _ => new DataTable()
+        };
+        _grids[table].DataSource = data;
+    }
+
+    private void EditSelected(string table)
+    {
+        var row = _grids[table].CurrentRow;
+        if (row?.Cells["ID"].Value is null) return;
+        EditRecord(table, Convert.ToInt32(row.Cells["ID"].Value));
+    }
+
+    private void EditRecord(string table, int? id)
+    {
+        try
+        {
+            using var form = new RecordForm(_db, table, id);
+            if (form.ShowDialog(this) != DialogResult.OK) return;
+            LoadTable(table);
+            LoadReports();
+            LoadAudit();
+        }
+        catch (Exception ex)
+        {
+            MessageBox.Show(ex.Message, "Ошибка", MessageBoxButtons.OK, MessageBoxIcon.Error);
+        }
+    }
+
+    private void DeleteSelected(string table)
+    {
+        var row = _grids[table].CurrentRow;
+        if (row?.Cells["ID"].Value is null) return;
+        var id = Convert.ToInt32(row.Cells["ID"].Value);
+        if (MessageBox.Show("Удалить выбранную запись?", "Подтверждение", MessageBoxButtons.YesNo, MessageBoxIcon.Warning) != DialogResult.Yes)
+            return;
+
+        try
+        {
+            var pk = table switch { "clients" => "client_id", "cars" => "car_id", "brands" => "brand_id", "rentals" => "rental_id", _ => "id" };
+            var dbTable = table.TrimEnd('s');
+            if (table == "clients") dbTable = "client";
+            if (table == "cars") dbTable = "car";
+            if (table == "brands") dbTable = "brand";
+            if (table == "rentals") dbTable = "rental";
+            _db.ExecuteNonQuery($"DELETE FROM {dbTable} WHERE {pk} = @id", new() { ["@id"] = id });
+            LoadTable(table);
+            LoadReports();
+            LoadAudit();
+        }
+        catch (Exception ex)
+        {
+            MessageBox.Show("Удаление невозможно: запись используется в связанных таблицах или нарушает ограничения.\n\n" + ex.Message,
+                "Защита целостности", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+        }
+    }
+
+    private void LoadAudit()
+    {
+        _grids["audit"].DataSource = _db.Query("""
+            SELECT audit_id AS ID, changed_at AS Время, user_login AS Пользователь,
+                   action AS Операция, entity AS Таблица, entity_id AS Запись, details AS Детали
+            FROM audit_log
+            ORDER BY audit_id DESC
+            LIMIT 300
+            """);
+    }
+
+    private void LoadReports()
+    {
+        var box = FindReportBox();
+        box.Text = BuildReportText();
+    }
+
+    private string BuildReportText()
+    {
+        var stats = _db.Query("""
+            SELECT
+              (SELECT COUNT(*) FROM client) AS clients,
+              (SELECT COUNT(*) FROM car) AS cars,
+              (SELECT COUNT(*) FROM rental WHERE status = 'Активна') AS active_rentals,
+              (SELECT COALESCE(SUM(total_cost),0) FROM rental WHERE status = 'Завершена') AS revenue
+            """).Rows[0];
+        var sb = new StringBuilder();
+        sb.AppendLine("ОТЧЕТ ПО СИСТЕМЕ АВТОПРОКАТА");
+        sb.AppendLine($"Дата формирования: {DateTime.Now:dd.MM.yyyy HH:mm}");
+        sb.AppendLine($"Пользователь: {_session.FullName} ({RoleName(_session.Role)})");
+        sb.AppendLine(new string('=', 72));
+        sb.AppendLine($"Клиентов: {stats["clients"]}");
+        sb.AppendLine($"Автомобилей: {stats["cars"]}");
+        sb.AppendLine($"Активных аренд: {stats["active_rentals"]}");
+        sb.AppendLine($"Выручка по завершенным арендам: {Convert.ToDecimal(stats["revenue"]).ToString("N2", CultureInfo.GetCultureInfo("ru-RU"))} руб.");
+        sb.AppendLine();
+
+        AppendTable(sb, "Активные договоры", _db.Query("SELECT * FROM v_rentals_full WHERE status='Активна' ORDER BY start_date DESC"));
+        AppendTable(sb, "Статистика по клиентам", _db.Query("SELECT full_name, total_rentals, total_spent FROM v_client_stats ORDER BY total_spent DESC"));
+        AppendTable(sb, "Свободные автомобили", _db.Query("SELECT * FROM v_available_cars ORDER BY daily_rate DESC"));
+        return sb.ToString();
+    }
+
+    private static void AppendTable(StringBuilder sb, string title, DataTable table)
+    {
+        sb.AppendLine(title);
+        sb.AppendLine(new string('-', title.Length));
+        if (table.Rows.Count == 0)
+        {
+            sb.AppendLine("Нет данных.");
+            sb.AppendLine();
+            return;
+        }
+
+        sb.AppendLine(string.Join(" | ", table.Columns.Cast<DataColumn>().Select(c => c.ColumnName)));
+        foreach (DataRow row in table.Rows)
+            sb.AppendLine(string.Join(" | ", row.ItemArray.Select(v => Convert.ToString(v))));
+        sb.AppendLine();
+    }
+
+    private void SaveReport(string format)
+    {
+        var reportsDir = Path.Combine(AppContext.BaseDirectory, "Reports");
+        Directory.CreateDirectory(reportsDir);
+        var path = Path.Combine(reportsDir, $"autorent_report_{DateTime.Now:yyyyMMdd_HHmmss}.{format}");
+        var text = BuildReportText();
+
+        if (format == "html")
+        {
+            var html = "<!doctype html><meta charset=\"utf-8\"><title>Отчет Автопрокат</title>" +
+                       "<style>body{font-family:Segoe UI,Arial;margin:32px}pre{white-space:pre-wrap;font:14px Consolas}</style>" +
+                       "<h1>Отчет по системе Автопрокат</h1><pre>" + System.Net.WebUtility.HtmlEncode(text) + "</pre>";
+            File.WriteAllText(path, html, Encoding.UTF8);
+        }
+        else
+        {
+            File.WriteAllText(path, text, Encoding.UTF8);
+        }
+
+        MessageBox.Show($"Отчет сохранен:\n{path}", "Готово", MessageBoxButtons.OK, MessageBoxIcon.Information);
+        try { Process.Start(new ProcessStartInfo(path) { UseShellExecute = true }); } catch { }
+    }
+
+    private void ExportCsv(string table)
+    {
+        if (_grids[table].DataSource is not DataTable data) return;
+        var reportsDir = Path.Combine(AppContext.BaseDirectory, "Reports");
+        Directory.CreateDirectory(reportsDir);
+        var path = Path.Combine(reportsDir, $"{table}_{DateTime.Now:yyyyMMdd_HHmmss}.csv");
+        var sb = new StringBuilder();
+        sb.AppendLine(string.Join(";", data.Columns.Cast<DataColumn>().Select(c => EscapeCsv(c.ColumnName))));
+        foreach (DataRow row in data.Rows)
+            sb.AppendLine(string.Join(";", row.ItemArray.Select(v => EscapeCsv(Convert.ToString(v) ?? ""))));
+        File.WriteAllText(path, sb.ToString(), new UTF8Encoding(true));
+        MessageBox.Show($"CSV сохранен:\n{path}", "Экспорт", MessageBoxButtons.OK, MessageBoxIcon.Information);
+    }
+
+    private static string EscapeCsv(string value) => "\"" + value.Replace("\"", "\"\"") + "\"";
+
+    private TextBox FindReportBox()
+    {
+        return _tabs.TabPages.Cast<TabPage>().SelectMany(p => p.Controls.Find("reportBox", true)).OfType<TextBox>().First();
+    }
+
+    private static string RoleName(string role) => role switch
+    {
+        "admin" => "Администратор",
+        "manager" => "Менеджер",
+        "viewer" => "Аналитик",
+        _ => role
+    };
+
+    private string SecurityText() => """
+        Пользователи и обязанности
+
+        1. admin / Администратор БД
+           Полный доступ к справочникам, автомобилям, клиентам, договорам, отчетам и журналу аудита.
+           Отвечает за структуру БД, учетные записи, резервное копирование и контроль целостности.
+
+        2. manager / Менеджер проката
+           Вводит клиентов, автомобили и договоры аренды, редактирует рабочие данные, формирует отчеты.
+           Не управляет ролями и справочником марок без администратора.
+
+        3. viewer / Аналитик
+           Имеет доступ только к просмотру данных и формированию отчетов. Изменение и удаление записей запрещено.
+
+        Защита на уровне приложения
+
+        - вход по логину и паролю;
+        - хранение паролей в виде PBKDF2-SHA256 хеша с индивидуальной солью;
+        - разграничение прав по ролям admin, manager, viewer;
+        - запрет кнопок добавления, редактирования и удаления для роли viewer;
+        - параметризованные SQL-запросы вместо конкатенации пользовательского ввода;
+        - обработка ошибок ограничений БД без раскрытия лишней служебной информации пользователю.
+
+        Защита на уровне базы данных
+
+        - первичные и внешние ключи;
+        - UNIQUE для госномера, телефона, водительского удостоверения и названия марки;
+        - CHECK для статусов, положительной цены, корректного года выпуска и дат аренды;
+        - ON DELETE RESTRICT для защиты связанных договоров от случайного удаления;
+        - представления v_rentals_full, v_available_cars, v_client_stats для отчетности;
+        - триггеры INSERT/UPDATE/DELETE на основных таблицах пишут операции в audit_log;
+        - PRAGMA foreign_keys = ON и WAL-журналирование при работе приложения.
+        """;
+}
+
+internal sealed class RecordForm : Form
+{
+    private readonly Database _db;
+    private readonly string _table;
+    private readonly int? _id;
+    private readonly Dictionary<string, Control> _fields = new();
+
+    public RecordForm(Database db, string table, int? id)
+    {
+        _db = db;
+        _table = table;
+        _id = id;
+        Text = id is null ? "Добавить запись" : "Редактировать запись";
+        StartPosition = FormStartPosition.CenterParent;
+        FormBorderStyle = FormBorderStyle.FixedDialog;
+        MaximizeBox = false;
+        MinimizeBox = false;
+        ClientSize = new Size(520, table == "rentals" ? 430 : 350);
+        Font = new Font("Segoe UI", 10F);
+
+        BuildForm();
+    }
+
+    private void BuildForm()
+    {
+        var panel = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(16), ColumnCount = 2, AutoScroll = true };
+        panel.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 170));
+        panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
+
+        var data = _id is null ? null : LoadRecord();
+        foreach (var field in Fields())
+            AddField(panel, field, data);
+
+        var buttons = new FlowLayoutPanel { FlowDirection = FlowDirection.RightToLeft, Dock = DockStyle.Fill };
+        var save = new Button { Text = "Сохранить", Width = 110, Height = 32 };
+        var cancel = new Button { Text = "Отмена", Width = 90, Height = 32 };
+        save.Click += (_, _) => Save();
+        cancel.Click += (_, _) => Close();
+        buttons.Controls.AddRange([save, cancel]);
+        panel.Controls.Add(new Label(), 0, panel.RowCount);
+        panel.Controls.Add(buttons, 1, panel.RowCount++);
+        Controls.Add(panel);
+        AcceptButton = save;
+    }
+
+    private DataRow LoadRecord()
+    {
+        var (dbTable, pk) = DbTable();
+        return _db.Query($"SELECT * FROM {dbTable} WHERE {pk}=@id", new() { ["@id"] = _id }).Rows[0];
+    }
+
+    private IEnumerable<(string Name, string Label, string Type)> Fields() => _table switch
+    {
+        "clients" => [("full_name", "ФИО", "text"), ("phone", "Телефон", "text"), ("address", "Адрес", "text"), ("license_no", "Водительское удостоверение", "text")],
+        "cars" => [("license_plate", "Госномер", "text"), ("brand_id", "Марка", "brand"), ("year", "Год выпуска", "number"), ("daily_rate", "Цена за день", "money"), ("status", "Статус", "car_status")],
+        "brands" => [("name", "Марка", "text"), ("country", "Страна", "text")],
+        "rentals" => [("client_id", "Клиент", "client"), ("car_id", "Автомобиль", "car"), ("start_date", "Дата начала", "date"), ("end_date", "Дата окончания", "date"), ("actual_return", "Фактический возврат", "date"), ("total_cost", "Сумма", "money"), ("status", "Статус", "rental_status")],
+        _ => []
+    };
+
+    private void AddField(TableLayoutPanel panel, (string Name, string Label, string Type) field, DataRow? data)
+    {
+        panel.RowStyles.Add(new RowStyle(SizeType.Absolute, 42));
+        panel.Controls.Add(new Label { Text = field.Label, AutoSize = true, Anchor = AnchorStyles.Left }, 0, panel.RowCount);
+
+        Control input = field.Type switch
+        {
+            "brand" => Combo("SELECT brand_id, name FROM brand ORDER BY name", "brand_id", data),
+            "client" => Combo("SELECT client_id, full_name FROM client ORDER BY full_name", "client_id", data),
+            "car" => Combo("SELECT car_id, license_plate || ' - ' || (SELECT name FROM brand WHERE brand_id=car.brand_id) FROM car ORDER BY license_plate", "car_id", data),
+            "car_status" => ComboValues(["Свободен", "В аренде", "На ТО"], field.Name, data),
+            "rental_status" => ComboValues(["Активна", "Завершена"], field.Name, data),
+            "date" => new DateTimePicker { Width = 280, Format = DateTimePickerFormat.Short, ShowCheckBox = field.Name == "actual_return" },
+            _ => new TextBox { Width = 280 }
+        };
+
+        if (input is TextBox tb)
+            tb.Text = data is null ? "" : Convert.ToString(data[field.Name]);
+        if (input is DateTimePicker dtp)
+        {
+            var value = data is null ? "" : Convert.ToString(data[field.Name]);
+            if (DateTime.TryParse(value, out var date))
+            {
+                dtp.Value = date;
+                dtp.Checked = true;
+            }
+            else
+            {
+                dtp.Value = DateTime.Today;
+                dtp.Checked = field.Name != "actual_return";
+            }
+        }
+
+        input.Anchor = AnchorStyles.Left;
+        _fields[field.Name] = input;
+        panel.Controls.Add(input, 1, panel.RowCount++);
+    }
+
+    private ComboBox Combo(string sql, string fieldName, DataRow? data)
+    {
+        var combo = new ComboBox { Width = 280, DropDownStyle = ComboBoxStyle.DropDownList };
+        var table = _db.Query(sql);
+        combo.DataSource = table;
+        combo.ValueMember = table.Columns[0].ColumnName;
+        combo.DisplayMember = table.Columns[1].ColumnName;
+        if (data is not null && data[fieldName] != DBNull.Value)
+            combo.SelectedValue = Convert.ToInt32(data[fieldName]);
+        return combo;
+    }
+
+    private static ComboBox ComboValues(string[] values, string fieldName, DataRow? data)
+    {
+        var combo = new ComboBox { Width = 280, DropDownStyle = ComboBoxStyle.DropDownList };
+        combo.Items.AddRange(values);
+        combo.SelectedItem = data is null ? values[0] : Convert.ToString(data[fieldName]);
+        return combo;
+    }
+
+    private void Save()
+    {
+        try
+        {
+            ValidateRequired();
+            var (dbTable, pk) = DbTable();
+            var fields = Fields().Select(f => f.Name).ToArray();
+            var values = fields.ToDictionary(f => "@" + f, ReadValue);
+
+            if (_id is null)
+            {
+                var sql = $"INSERT INTO {dbTable}({string.Join(",", fields)}) VALUES({string.Join(",", values.Keys)})";
+                _db.ExecuteNonQuery(sql, values);
+            }
+            else
+            {
+                values["@id"] = _id;
+                var set = string.Join(",", fields.Select(f => $"{f}=@{f}"));
+                _db.ExecuteNonQuery($"UPDATE {dbTable} SET {set} WHERE {pk}=@id", values);
+            }
+
+            DialogResult = DialogResult.OK;
+            Close();
+        }
+        catch (Exception ex)
+        {
+            MessageBox.Show(ex.Message, "Ошибка сохранения", MessageBoxButtons.OK, MessageBoxIcon.Error);
+        }
+    }
+
+    private void ValidateRequired()
+    {
+        foreach (var field in Fields())
+        {
+            if (_fields[field.Name] is TextBox tb && string.IsNullOrWhiteSpace(tb.Text) && field.Name is "full_name" or "license_plate" or "name")
+                throw new InvalidOperationException($"Заполните поле «{field.Label}».");
+        }
+    }
+
+    private object? ReadValue(string parameterName)
+    {
+        var name = parameterName.TrimStart('@');
+        var control = _fields[name];
+        return control switch
+        {
+            TextBox tb when name is "year" => int.TryParse(tb.Text, out var i) ? i : null,
+            TextBox tb when name is "daily_rate" or "total_cost" => decimal.TryParse(tb.Text.Replace(',', '.'), NumberStyles.Any, CultureInfo.InvariantCulture, out var d) ? d : null,
+            TextBox tb => string.IsNullOrWhiteSpace(tb.Text) ? null : tb.Text.Trim(),
+            ComboBox cb => cb.SelectedValue is DataRowView ? cb.Text : cb.SelectedValue ?? cb.SelectedItem,
+            DateTimePicker dtp => dtp.Checked ? dtp.Value.ToString("yyyy-MM-dd") : null,
+            _ => null
+        };
+    }
+
+    private (string Table, string Pk) DbTable() => _table switch
+    {
+        "clients" => ("client", "client_id"),
+        "cars" => ("car", "car_id"),
+        "brands" => ("brand", "brand_id"),
+        "rentals" => ("rental", "rental_id"),
+        _ => throw new InvalidOperationException("Неизвестная таблица.")
+    };
+}

+ 101 - 0
Program.cs

@@ -0,0 +1,101 @@
+using System.Windows.Forms;
+
+namespace AutorentWinForms;
+
+internal static class Program
+{
+    [STAThread]
+    private static void Main(string[] args)
+    {
+        ApplicationConfiguration.Initialize();
+
+        var connectionString = AppSettings.LoadConnectionString();
+        using var db = new Database(connectionString);
+
+        if (args.Contains("--probe"))
+        {
+            var perms = db.Query("""
+                SELECT current_database() AS db,
+                       has_database_privilege(current_user, current_database(), 'CREATE') AS can_create_db,
+                       has_schema_privilege(current_user, 'public', 'CREATE') AS can_create_public
+                """);
+            Console.WriteLine($"CURRENT {perms.Rows[0]["db"]} CREATE_DB={perms.Rows[0]["can_create_db"]} CREATE_PUBLIC={perms.Rows[0]["can_create_public"]}");
+            var databases = db.Query("SELECT datname FROM pg_database WHERE datallowconn ORDER BY datname");
+            Console.WriteLine("DATABASES");
+            foreach (System.Data.DataRow row in databases.Rows)
+                Console.WriteLine(row["datname"]);
+            Console.WriteLine("SCHEMAS");
+            var schemas = db.Query("SELECT schema_name FROM information_schema.schemata ORDER BY schema_name");
+            foreach (System.Data.DataRow row in schemas.Rows)
+                Console.WriteLine(row["schema_name"]);
+            Console.WriteLine("TABLES");
+            var tables = db.Query("""
+                SELECT table_schema, table_name
+                FROM information_schema.tables
+                WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
+                ORDER BY table_schema, table_name
+                LIMIT 80
+                """);
+            foreach (System.Data.DataRow row in tables.Rows)
+                Console.WriteLine($"{row["table_schema"]}.{row["table_name"]}");
+            Console.WriteLine("COLUMNS");
+            var columns = db.Query("""
+                SELECT table_name, column_name, data_type
+                FROM information_schema.columns
+                WHERE table_schema = current_schema()
+                  AND table_name IN ('brand','car','client','rental','app_user','audit_log')
+                ORDER BY table_name, ordinal_position
+                """);
+            foreach (System.Data.DataRow row in columns.Rows)
+                Console.WriteLine($"{row["table_name"]}.{row["column_name"]}:{row["data_type"]}");
+            return;
+        }
+
+        db.Initialize();
+
+        if (args.Contains("--smoke-test"))
+        {
+            var clients = db.ExecuteScalarInt("SELECT COUNT(*) FROM client");
+            var audit = db.ExecuteScalarInt("SELECT COUNT(*) FROM audit_log");
+            var adminAuth = db.Authenticate("garanin_gi", "6qw#6xUyG%#")?.Role == "admin";
+            var managerAuth = db.Authenticate("manager", "Manager123!")?.Role == "manager";
+            var viewerAuth = db.Authenticate("shagavaliev_iv", "75&n057$380")?.Role == "viewer";
+            Console.WriteLine($"OK: clients={clients}, audit={audit}, adminAuth={adminAuth}, managerAuth={managerAuth}, viewerAuth={viewerAuth}");
+            return;
+        }
+
+        if (args.Contains("--insert-test"))
+        {
+            var stamp = DateTime.Now.ToString("yyyyMMddHHmmss");
+            db.ExecuteNonQuery("BEGIN");
+            try
+            {
+                db.ExecuteNonQuery(
+                    """
+                    INSERT INTO client(full_name, phone, address, license_no)
+                    VALUES(@name, @phone, @address, @license)
+                    """,
+                    new()
+                    {
+                        ["@name"] = "Проверка sequence",
+                        ["@phone"] = "+7999" + stamp,
+                        ["@address"] = "Тест",
+                        ["@license"] = "TEST" + stamp
+                    });
+                var id = db.ExecuteScalarInt("SELECT MAX(client_id) FROM client");
+                Console.WriteLine($"OK: inserted client_id={id}");
+            }
+            finally
+            {
+                db.ExecuteNonQuery("ROLLBACK");
+            }
+            return;
+        }
+
+        using var login = new LoginForm(db);
+        if (login.ShowDialog() != DialogResult.OK || login.Session is null)
+            return;
+
+        Application.Run(new MainForm(db, login.Session));
+    }
+}

+ 54 - 0
README.md

@@ -0,0 +1,54 @@
+# Автопрокат: Windows Forms приложение и защита БД
+
+Проект реализует настольное приложение на C# Windows Forms для предметной области «Автопрокат».
+
+## Запуск
+
+```powershell
+dotnet run
+```
+
+Приложение подключается к PostgreSQL через файл `dbsettings.json`. Сейчас оно настроено на учебный сервер `edu-pg.itiscaf.ru`, базу `dbwork`, схему `garanin_gi`.
+
+Строку подключения также можно передать через переменную окружения `AUTORENT_CONNECTION_STRING`.
+
+Пользователь для входа:
+
+| Логин | Пароль | Роль | Обязанности |
+|---|---|---|---|
+| `garanin_gi` | пароль от PostgreSQL | Администратор | Полный доступ к данным, отчетам и журналу аудита |
+| `manager` | `Manager123!` | Менеджер | Ввод и редактирование рабочих данных, формирование отчетов |
+| `shagavaliev_iv` | пароль viewer-пользователя | Аналитик | Просмотр данных и отчетов без права изменения |
+
+## Реализованные функции
+
+- ввод, поиск, редактирование и удаление клиентов;
+- ввод, поиск, редактирование и удаление автомобилей;
+- управление справочником марок, доступно администратору;
+- оформление и сопровождение договоров аренды;
+- CSV-экспорт текущих таблиц;
+- формирование отчетных документов в `HTML` и `TXT`;
+- просмотр журнала операций.
+
+## Защита на уровне приложения
+
+- авторизация по логину и паролю;
+- пароли хранятся не в открытом виде, а как `PBKDF2-SHA256` хеш с индивидуальной солью;
+- разграничение прав по ролям `admin`, `manager`, `viewer`;
+- запрет операций изменения для аналитика;
+- параметризованные запросы к БД;
+- пользовательские сообщения об ошибках целостности без раскрытия лишних внутренних деталей.
+
+## Защита на уровне БД
+
+В локальной демонстрационной БД реализованы:
+
+- первичные и внешние ключи;
+- ограничения `UNIQUE` для госномеров, телефонов, водительских удостоверений и названий марок;
+- ограничения `CHECK` для статусов, года выпуска, цены и дат аренды;
+- `ON DELETE RESTRICT` для защиты связанных данных;
+- представления `v_rentals_full`, `v_available_cars`, `v_client_stats`;
+- триггеры аудита `INSERT`, `UPDATE`, `DELETE` для таблиц `brand`, `car`, `client`, `rental`;
+- журнал `audit_log`.
+
+Файл [database_security_postgresql.sql](database_security_postgresql.sql) содержит вариант DDL для PostgreSQL с ролями БД, правами доступа, Row Level Security и триггерами логирования.

+ 175 - 0
database_security_postgresql.sql

@@ -0,0 +1,175 @@
+-- Автопрокат: PostgreSQL 16, схема, роли, права и аудит операций
+
+CREATE TABLE IF NOT EXISTS brand (
+  brand_id  SERIAL PRIMARY KEY,
+  name      VARCHAR(50) NOT NULL CONSTRAINT uq_brand_name UNIQUE,
+  country   VARCHAR(50)
+);
+
+CREATE TABLE IF NOT EXISTS car (
+  car_id        SERIAL PRIMARY KEY,
+  license_plate VARCHAR(15) NOT NULL CONSTRAINT uq_car_plate UNIQUE,
+  brand_id      INT NOT NULL CONSTRAINT fk_car_brand
+                REFERENCES brand(brand_id) ON UPDATE CASCADE ON DELETE RESTRICT,
+  year          SMALLINT CONSTRAINT chk_car_year CHECK (year BETWEEN 1990 AND 2030),
+  daily_rate    NUMERIC(10,2) NOT NULL CONSTRAINT chk_car_rate CHECK (daily_rate > 0),
+  status        VARCHAR(20) DEFAULT 'Свободен'
+                CONSTRAINT chk_car_status CHECK (status IN ('Свободен','В аренде','На ТО'))
+);
+
+CREATE TABLE IF NOT EXISTS client (
+  client_id  SERIAL PRIMARY KEY,
+  full_name  VARCHAR(100) NOT NULL,
+  phone      VARCHAR(20) CONSTRAINT uq_client_phone UNIQUE,
+  address    TEXT,
+  license_no VARCHAR(20) CONSTRAINT uq_client_license UNIQUE,
+  created_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS rental (
+  rental_id     SERIAL PRIMARY KEY,
+  car_id        INT NOT NULL CONSTRAINT fk_rental_car
+                REFERENCES car(car_id) ON UPDATE CASCADE ON DELETE RESTRICT,
+  client_id     INT NOT NULL CONSTRAINT fk_rental_client
+                REFERENCES client(client_id) ON UPDATE CASCADE ON DELETE RESTRICT,
+  start_date    DATE NOT NULL,
+  end_date      DATE NOT NULL CONSTRAINT chk_rental_dates CHECK (end_date >= start_date),
+  actual_return DATE,
+  total_cost    NUMERIC(10,2) CONSTRAINT chk_rental_cost CHECK (total_cost >= 0),
+  status        VARCHAR(20) DEFAULT 'Активна'
+                CONSTRAINT chk_rental_status CHECK (status IN ('Активна','Завершена'))
+);
+
+CREATE INDEX IF NOT EXISTS idx_car_brand_id ON car(brand_id);
+CREATE INDEX IF NOT EXISTS idx_rental_car_id ON rental(car_id);
+CREATE INDEX IF NOT EXISTS idx_rental_client_id ON rental(client_id);
+CREATE INDEX IF NOT EXISTS idx_rental_dates ON rental(start_date, end_date);
+CREATE INDEX IF NOT EXISTS idx_rental_status ON rental(status);
+
+CREATE OR REPLACE VIEW v_rentals_full AS
+  SELECT r.rental_id,
+         c.full_name AS client_name,
+         c.phone AS client_phone,
+         ca.license_plate,
+         b.name AS brand_name,
+         r.start_date,
+         r.end_date,
+         r.actual_return,
+         r.total_cost,
+         r.status
+  FROM rental r
+  JOIN client c ON c.client_id = r.client_id
+  JOIN car ca ON ca.car_id = r.car_id
+  JOIN brand b ON b.brand_id = ca.brand_id;
+
+CREATE OR REPLACE VIEW v_available_cars AS
+  SELECT ca.car_id, ca.license_plate, b.name AS brand, b.country, ca.year, ca.daily_rate
+  FROM car ca
+  JOIN brand b ON b.brand_id = ca.brand_id
+  WHERE ca.status = 'Свободен';
+
+CREATE OR REPLACE VIEW v_client_stats AS
+  SELECT c.client_id,
+         c.full_name,
+         COUNT(r.rental_id) AS total_rentals,
+         COALESCE(SUM(r.total_cost), 0) AS total_spent
+  FROM client c
+  LEFT JOIN rental r ON r.client_id = c.client_id
+  GROUP BY c.client_id, c.full_name;
+
+-- Пользователи предметной области
+CREATE ROLE rent_admin NOLOGIN;
+CREATE ROLE rent_manager NOLOGIN;
+CREATE ROLE rent_viewer NOLOGIN;
+
+-- Администратор: полный доступ
+GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO rent_admin;
+GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO rent_admin;
+
+-- Менеджер: рабочий CRUD без управления пользователями и системной структурой
+GRANT SELECT, INSERT, UPDATE ON client, car, rental TO rent_manager;
+GRANT SELECT ON brand, v_rentals_full, v_available_cars, v_client_stats TO rent_manager;
+GRANT USAGE ON SEQUENCE client_client_id_seq, car_car_id_seq, rental_rental_id_seq TO rent_manager;
+
+-- Аналитик: только чтение и отчеты
+GRANT SELECT ON brand, car, client, rental, v_rentals_full, v_available_cars, v_client_stats TO rent_viewer;
+
+-- Примеры учетных записей БД. В реальном проекте пароли задаются администратором отдельно.
+-- CREATE USER ivanov WITH PASSWORD 'Str0ng#Pass1' IN ROLE rent_manager;
+-- CREATE USER petrov WITH PASSWORD 'Str0ng#Pass2' IN ROLE rent_viewer;
+-- CREATE USER db_admin WITH PASSWORD 'Str0ng#Admin1' IN ROLE rent_admin;
+
+-- Row Level Security: явное разрешение по ролям.
+ALTER TABLE rental ENABLE ROW LEVEL SECURITY;
+
+DROP POLICY IF EXISTS rental_admin_all ON rental;
+CREATE POLICY rental_admin_all ON rental
+  FOR ALL TO rent_admin
+  USING (true)
+  WITH CHECK (true);
+
+DROP POLICY IF EXISTS rental_manager_all ON rental;
+CREATE POLICY rental_manager_all ON rental
+  FOR ALL TO rent_manager
+  USING (true)
+  WITH CHECK (true);
+
+DROP POLICY IF EXISTS rental_viewer_read ON rental;
+CREATE POLICY rental_viewer_read ON rental
+  FOR SELECT TO rent_viewer
+  USING (true);
+
+-- Аудит операций средствами СУБД
+CREATE TABLE IF NOT EXISTS audit_log (
+  audit_id   BIGSERIAL PRIMARY KEY,
+  changed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+  db_user    TEXT NOT NULL DEFAULT current_user,
+  action     TEXT NOT NULL,
+  table_name TEXT NOT NULL,
+  row_id     TEXT,
+  old_data   JSONB,
+  new_data   JSONB
+);
+
+CREATE OR REPLACE FUNCTION fn_audit_row()
+RETURNS TRIGGER
+LANGUAGE plpgsql
+AS $$
+DECLARE
+  pk_name TEXT := TG_ARGV[0];
+BEGIN
+  IF TG_OP = 'INSERT' THEN
+    INSERT INTO audit_log(action, table_name, row_id, new_data)
+    VALUES (TG_OP, TG_TABLE_NAME, to_jsonb(NEW)->>pk_name, to_jsonb(NEW));
+    RETURN NEW;
+  ELSIF TG_OP = 'UPDATE' THEN
+    INSERT INTO audit_log(action, table_name, row_id, old_data, new_data)
+    VALUES (TG_OP, TG_TABLE_NAME, to_jsonb(NEW)->>pk_name, to_jsonb(OLD), to_jsonb(NEW));
+    RETURN NEW;
+  ELSE
+    INSERT INTO audit_log(action, table_name, row_id, old_data)
+    VALUES (TG_OP, TG_TABLE_NAME, to_jsonb(OLD)->>pk_name, to_jsonb(OLD));
+    RETURN OLD;
+  END IF;
+END;
+$$;
+
+DROP TRIGGER IF EXISTS trg_audit_brand ON brand;
+CREATE TRIGGER trg_audit_brand
+AFTER INSERT OR UPDATE OR DELETE ON brand
+FOR EACH ROW EXECUTE FUNCTION fn_audit_row('brand_id');
+
+DROP TRIGGER IF EXISTS trg_audit_car ON car;
+CREATE TRIGGER trg_audit_car
+AFTER INSERT OR UPDATE OR DELETE ON car
+FOR EACH ROW EXECUTE FUNCTION fn_audit_row('car_id');
+
+DROP TRIGGER IF EXISTS trg_audit_client ON client;
+CREATE TRIGGER trg_audit_client
+AFTER INSERT OR UPDATE OR DELETE ON client
+FOR EACH ROW EXECUTE FUNCTION fn_audit_row('client_id');
+
+DROP TRIGGER IF EXISTS trg_audit_rental ON rental;
+CREATE TRIGGER trg_audit_rental
+AFTER INSERT OR UPDATE OR DELETE ON rental
+FOR EACH ROW EXECUTE FUNCTION fn_audit_row('rental_id');