JavaRush /Курси /ChatGPT Apps /Audit & lifecycle: журнали аудиту, зберігання даних, ...

Audit & lifecycle: журнали аудиту, зберігання даних, видалення на запит, continuity/backups

ChatGPT Apps
Рівень 15 , Лекція 4
Відкрита

1. Навіщо вам узагалі audit & lifecycle у ChatGPT App

Поки ви пишете прототип, користувач — це ви самі, БД — локальна SQLite, а «інциденти» лікуються git reset --hard — і все здається милим та домашнім.

А щойно ваш GiftGenius (або інший ChatGPT‑застосунок (App)) отримує реальних користувачів — особливо якщо є платежі й PII, — одразу зʼявляються:

  • фахівці з безпеки клієнта із запитанням: «хто може бачити наші замовлення й хто їх змінював?»;
  • юристи із запитанням: «як довго ви зберігаєте дані й як виконуєте запит „видаліть мене“?»;
  • продакшн‑реальність із запитанням: «що буде, якщо розробник випадково видалить таблицю в продакшені?».

У цій лекції розглянемо чотири опорні блоки:

  1. Журнали аудиту — окремий шар логування для безпеки та аудиту.
  2. Data retention — строки життя різних типів даних і те, як це реалізувати.
  3. Видалення на запит користувача — «право на забуття» у технічній реалізації.
  4. Business continuity & backups — як переживати збої, не втрачаючи ані обличчя, ані даних.

Усі приклади, де це можливо, привʼязуватимемо до нашого навчального застосунку (умовний GiftGenius на Next.js + Apps SDK + MCP).

2. Журнали аудиту: хто, що, коли й чим усе закінчилося

Чим журнали аудиту відрізняються від звичайних логів

Звичайні логи застосунку — це дружні повідомлення для розробника. У них є stack trace, debug‑інформація, дивні значення змінних, налагоджувальні console.log("тут точно не має бути null"). Такі логи живуть недовго й читаються інженерами.

Журнали аудиту — це інший всесвіт. Їхня основна аудиторія — фахівці з безпеки, аудитори, іноді юристи. Їм не потрібен рядок на кшталт «NullPointer на 55‑му рядку». Їм потрібен запис на кшталт: «користувач X змінив платіжні налаштування організації Y у такий‑то час; результат — успішно». Audit‑записи зазвичай живуть значно довше (роками) і вважаються доказом у разі розслідувань.

Ключові відмінності:

Характеристика Application Logs Audit Logs
Мета Налагодження, діагностика Безпека, комплаєнс, розслідування
Аудиторія Розробники, SRE Фахівці з безпеки, юристи, іноді регулятори
Склад даних Технічні деталі, stack trace Хто/що/коли/до якого ресурсу/з яким результатом
Строк зберігання Тижні — місяці Роки (часто ≥ 1 рік)
Операції над логами Можна видаляти/перезаписувати Бажано append‑only, без UPDATE/DELETE

OWASP і подібні настанови окремо наголошують: журнали аудиту краще тримати в окремому сховищі або таблиці, а не змішувати зі звичайними логами застосунку.

Що логувати в контексті ChatGPT‑застосунку

Для ChatGPT‑застосунку, особливо з комерцією, розумний мінімум аудиту такий:

  • події автентифікації: вхід у систему, вихід із системи, спроби входу;
  • операції з критичними даними: створення/оновлення/видалення профілів, замовлень, платіжних налаштувань;
  • адміністративні дії: зміна ролей, зміна налаштувань тенанта;
  • виклики чутливих інструментів MCP/Agents: create_order, charge_customer, cancel_subscription тощо.

Добра інтуїція проста: усе, про що під час інциденту ви захочете запитати «хто це зробив і чому?», має потрапляти в аудит.

Структура події аудиту

Зручна ментальна модель: кожен запис — це «хто / яка дія / над чим / у якому контексті / з яким результатом». Часто це формулюють як структуру who, action, resource, context, outcome.

Для нашого GiftGenius опишемо інтерфейс у TypeScript:

// lib/audit.ts
export type AuditAction =
  | "auth.login"
  | "auth.logout"
  | "order.create"
  | "order.cancel"
  | "account.delete"
  | "giftidea.generate";

export interface AuditEvent {
  eventId: string;          // uuid
  timestamp: string;        // ISO
  actor: {
    userId: string | null;  // може бути null до логіна
    tenantId?: string | null;
    ip?: string | null;
    client: "chatgpt-app" | "admin-panel" | string;
  };
  action: AuditAction;
  resource?: {
    type: string;           // "order", "user", ...
    id?: string;
  };
  context?: {
    mcpTool?: string;
    requestId?: string;
  };
  outcome: {
    status: "success" | "failure";
    reason?: string | null;
  };
}

Зверніть увагу: до події не потрапляють повні email‑адреси, номери карток та інша PII, яку ми в попередніх лекціях навчилися маскувати й не логувати без потреби.

Де і як зберігати журнали аудиту

Мінімальні вимоги до сховища:

  • окрема таблиця або навіть окрема БД, відокремлена від звичайних логів, — щоб було складніше щось випадково затерти;
  • за можливості режим append‑only: технічно це може бути просто політика «ми ніколи не робимо UPDATE/DELETE у цій таблиці», плюс роль БД, яка має лише права на INSERT і SELECT;
  • обмежений доступ: не весь інженерний штат має мати права читати повний аудит.

Якщо ви використовуєте PostgreSQL через Prisma/Drizzle, модель може виглядати так (спрощений приклад):

CREATE TABLE audit_events (
  event_id   uuid PRIMARY KEY,
  created_at timestamptz NOT NULL DEFAULT now(),
  actor_user_id text,
  actor_tenant_id text,
  actor_ip      inet,
  action        text NOT NULL,
  resource_type text,
  resource_id   text,
  context_mcp_tool text,
  context_request_id text,
  outcome_status text NOT NULL,
  outcome_reason text
);

Схему легко адаптувати під ваші потреби, але головне — структурованість. Хаотичний JSON в одному рядку ви потім самі ж проклянете.

Реалізація аудиту в нашому App

Зробимо невелику допоміжну функцію в Next.js‑застосунку (Node‑оточенні, наприклад у MCP‑сервері або API‑роуті):

// lib/audit.ts
import { randomUUID } from "crypto";
import { db } from "./db"; // ваш клієнт для БД

export async function logAudit(event: Omit<AuditEvent, "eventId" | "timestamp">) {
  const full: AuditEvent = {
    ...event,
    eventId: randomUUID(),
    timestamp: new Date().toISOString(),
  };

  // У реальному житті — через чергу/бекграунд, тут просто вставка
  await db.insertInto("audit_events").values({
    event_id: full.eventId,
    created_at: full.timestamp,
    actor_user_id: full.actor.userId,
    action: full.action,
    outcome_status: full.outcome.status,
    outcome_reason: full.outcome.reason ?? null,
    // ...інші поля
  });
}

Тепер додамо виклик в обробник, який створює замовлення (уявімо, що це MCP‑tool або серверний endpoint):

// app/api/orders/route.ts
export async function POST(req: Request) {
  const user = await requireUser(req); // з модуля аутентифікації
  const body = await req.json();
  const order = await createOrderInDb(user, body);

  await logAudit({
    actor: { userId: user.id, client: "chatgpt-app" },
    action: "order.create",
    resource: { type: "order", id: order.id },
    context: { mcpTool: "create_order_tool" },
    outcome: { status: "success" },
  });

  return Response.json(order);
}

Так само можна зробити й для небезпечних операцій — скасування замовлення, зміни платіжних реквізитів, видалення облікового запису.

Отже, у нас є окремий структурований шар аудиту — чудово. Наступне природне запитання: як довго всі ці події (і решта користувацьких даних) мають зберігатися та що з ними робити після завершення строку?

3. Data retention: скільки живуть ваші дані

Чому не можна зберігати все вічно

Інженерний інстинкт «а раптом знадобиться» у контексті користувацьких даних — небезпечний.

По‑перше, чим довше й чим більше даних ви накопичуєте, тим серйозніші наслідки у разі витоку: що товстіша бочка з бензином, то страшніша пожежа. У багатьох посібниках із захисту даних їх прямо називають «токсичним активом»: дані варто зберігати з користю, але мінімізувати і обсяг, і строк.

По‑друге, законодавство рівня GDPR/CCPA запроваджує принцип «не довше, ніж потрібно для мети обробки». Тобто персональні дані не можна тримати нескінченно «про всяк випадок». Для кожного типу даних мають бути чіткі строки зберігання та процедури видалення або анонімізації.

По‑третє, хмарне сховище коштує грошей. Великі таблиці логів і історії чатів швидко зростають, і за рік раптово виявляється, що половина рахунку від провайдера — це «вчорашнє сміття».

Різні дані — різні строки

Досвід компаній і публічні настанови дають приблизно таку картину:

Тип даних Типові строки зберігання
Debug‑логи, технічні метрики 1–12 місяців
Журнали аудиту ≥ 12 місяців, іноді 2–5 років
Замовлення, платежі, рахунки 3–7 років (за вимогами обліку/податків)
Сесії, тимчасові токени Години — дні
Сирі чати / запити від кількох тижнів до кількох місяців або взагалі не зберігаємо
Анонімні агрегати (аналітика) довше, оскільки PII уже немає

Важливо: це не юридична консультація, а інженерні орієнтири. Для реального продукту ви погоджуєте строки з юристами, але технічно маєте бути готові реалізувати різні TTL.

Як реалізувати retention у коді

Найпоширеніший підхід такий: у таблиці є created_at або expires_at, а також періодичний процес, який видаляє або анонімізує старі записи.

Приклад: чистка звичайних логів, старіших за 90 днів.

// scripts/cleanup-logs.ts
import { db } from "../lib/db";

async function cleanup() {
  await db
    .deleteFrom("app_logs")
    .where("created_at", "<", new Date(Date.now() - 90 * 24 * 60 * 60 * 1000));
  console.log("Старі логи видалено");
}

cleanup().catch(console.error);

Такий скрипт можна запускати за cronʼом, через GitHub Actions за розкладом або за допомогою планувальника в хмарі.

Для PII замість видалення часто роблять анонімізацію. Наприклад, замовлення, старші за N років, позбавляють звʼязку з конкретним користувачем:

UPDATE orders
SET user_id = NULL
WHERE created_at < now() - interval '3 years';

При цьому зберігаються суми, товари й інша «бухгалтерія», але зникає звʼязок із конкретною особою.

Не забувайте, що резервні копії теж мають свої строки життя. Періодичність і тривалість зберігання резервних копій ми обговоримо окремо в блоці про резервне копіювання, але сама ідея та сама: навіть архіви не можна тримати нескінченно, інакше «право на забуття» перетворюється на фікцію.

4. Видалення на запит користувача: «право на забуття» у коді

Звідки вимога

Європейський GDPR (і подібні закони) запроваджує так зване «право на забуття»: користувач може вимагати видалення своїх персональних даних, і компанія має зробити це без необґрунтованої затримки.

Із точки зору розробника такого застосунку це означає: рано чи пізно вам надійде запит «видаліть усі дані про мене» (або ви самі додасте кнопку «Delete my data»), і потрібно буде не лише видалити запис у таблиці users, а й пройтися по всьому сліду: замовлення, сесії, токени, журнали дій, CRM, платіжні системи тощо.

Але є й закони, які вимагають від вас зберігати певні дані: фінансові транзакції, наприклад. Тож тут більше юридичних складнощів, ніж технічних.

Що саме треба чистити

Мінімальний набір для нашого GiftGenius:

  • профіль користувача (імʼя, email, налаштування);
  • сесії, refresh‑токени, звʼязки з OAuth‑провайдерами;
  • замовлення, якщо вони не потрібні в «персоніфікованому вигляді» (або їх можна анонімізувати);
  • логи та audit‑записи, де всередині є PII (наприклад, email у сирому вигляді).

При цьому залишаються дані, важливі для звітності, але вже знеособлені — суми замовлень, кількість транзакцій, агрегати за країнами тощо.

Приклад алгоритму видалення

Схема сценарію:

  1. Користувач (авторизований) натискає «Видалити мій акаунт».
  2. На сервер надходить запит із його userId.
  3. Сервер:
    • видаляє/анонімізує залежні записи (замовлення, сесії, інтеграції);
    • чистить PII у профілі;
    • пише запис у журнал аудиту: «оброблено запит на видалення даних».

Для простоти покажемо мінімальний варіант на парі таблиць. У реальному продукті навколо цього ж ядра ви додасте додаткові сутності (інтеграції, сторонні сервіси тощо).

Сервісний код у Next.js (спрощений приклад):

// app/api/delete-me/route.ts
import { db } from "@/lib/db";
import { logAudit } from "@/lib/audit";

export async function POST(req: Request) {
  const user = await requireUser(req);

  await db.transaction(async (tx) => {
    await tx.deleteFrom("sessions").where("user_id", "=", user.id);
    await tx.deleteFrom("orders").where("user_id", "=", user.id);

    await tx.updateTable("users")
      .set({
        is_deleted: true,
        name: null,
        email: null,
      })
      .where("id", "=", user.id);

    await logAudit({
      actor: { userId: user.id, client: "chatgpt-app" },
      action: "account.delete",
      outcome: { status: "success" },
    });
  });

  return new Response(null, { status: 204 });
}

У реальному світі ви додасте сюди виклики зовнішніх API (наприклад, Stripe — щоб відвʼязати customer), і транзакцію зробите серйознішою. Але сам принцип уже є: усе в одному місці, з audit‑записом.

Взаємозвʼязок із резервними копіями

Із непростою частиною «а як же резервні копії» повʼязано безліч цікавих питань. Навіть якщо ви видалили користувача з робочої БД, його дані можуть залишитися в нічних snapshotʼах. Щоб це не перетворювалося на «ми ніколи нікого фактично не видаляємо», є два підходи:

  1. Резервні копії самі живуть обмежений час (наприклад, 30–90 днів) і через цей час зникають разом із даними. Після завершення retention ані основна БД, ані архіви не містять даних користувача.
  2. Якщо ви все‑таки піднімаєте систему з резервної копії, у вас є реєстр «видалених» ID, і після відновлення ви повторно запускаєте скрипти видалення/анонімізації.

У великих компаніях іноді практикують crypto‑shredding: PII користувача шифрується окремим ключем, і на запит видалення знищується саме ключ. Навіть якщо десь залишилися копії зашифрованих даних (у логах, резервних копіях), без ключа це марне сміття. Це ефективно, але для стартапу трохи «ракетобудування».

Важливий UX‑момент

Памʼятайте: видалення — це не лише SQL. Користувач очікує:

  • зрозумілий спосіб надіслати запит (кнопка, форма, email);
  • розумні строки виконання (на практиці це до 30 днів);
  • сповіщення про успіх або вмотивовану відмову (наприклад, коли частину даних потрібно зберігати за законом).

З технічного боку у вас уже є основа: ви вмієте виконати чистку, залогувати дію та не тримати зайвого в резервних копіях.

5. Business continuity & резервне копіювання

Тепер уявіть, що все вищезазначене працює ідеально… доки не стається фатальний DROP TABLE orders, збій у хмарі або падіння цілого регіону. Нам потрібні механізми, щоб повернути сервіс до життя у прийнятні терміни і не втратити критичні дані.

RTO і RPO — дві літери, що визначають ваш біль

Два базові параметри Disaster Recovery:

  • RTO (Recovery Time Objective) — скільки часу ви можете дозволити собі бути недоступними. Наприклад, якщо RTO = 1 година, отже після серйозного збою ви зобовʼязані підняти систему максимум за 1 годину.
  • RPO (Recovery Point Objective) — скільки даних у часі ви готові втратити. Якщо RPO = 10 хвилин, це означає, що під час відновлення можна втратити останні 10 хвилин історії, але не більше.

Чим критичніший продукт (банківські сервіси, торгові системи), тим ближчі обидва параметри до нуля. Для навчального GiftGenius можна жити з RTO на кілька годин і RPO близько 15–60 хвилин, але навіть це потрібно якось реалізувати.

Що може піти не так у вашому стеку

У контексті ChatGPT‑застосунку на Vercel + хмарна БД + зовнішні API список типових проблем такий:

  • OpenAI API недоступний: ваш App відповідає помилками на tool‑calls.
  • Vercel (або хтось інший) дає збій: віджет не може підʼєднатися до вашого бекенда.
  • База даних пошкоджена або щось випадково видалено (наприклад, DROP TABLE).
  • Зламано або скомпрометовано обліковий запис, що керує інфраструктурою.

На все це ви відповідаєте комбінацією резервних копій, реплікації та розумної поведінки застосунку під час збою.

Стратегії резервного копіювання

Сучасні хмарні Postgres/інші БД зазвичай дають щонайменше три варіанти:

  1. Повні резервні копії + інкрементальні.
    Робити повний snapshot БД раз на добу, а між ними зберігати інкрементальні зміни. Відновлення — відкат до конкретного snapshotʼа плюс прокрутка журналу змін.
  2. Point‑in‑Time Recovery (PITR).
    База пише журнал транзакцій (WAL) і дозволяє відновитися на довільний момент часу (наприклад: «стан на 14:03:00 — до того, як ми випадково виконали DROP TABLE»).
  3. Реплікація в інший регіон.
    Тримати пасивну або активну репліку БД в іншому регіоні/хмарі. У разі втрати основного регіону можна переключити застосунок на репліку, втративши лише ті дані, які ще не встигли доїхати.

Для нашого масштабу зазвичай достатньо увімкнути PITR у провайдера БД і налаштувати періодичні off‑site‑резервні копії.

Простий приклад: щоденний дамп для локальної або dev‑БД

Навіть якщо для продакшену ви покладаєтеся на керовану БД, для staging/dev іноді хочеться мати простий скрипт:

# scripts/backup.sh
#!/usr/bin/env bash
set -e
DATE=$(date +%F)
pg_dump "$DATABASE_URL" > "backups/backup-$DATE.sql"
echo "Бекап створено: backups/backup-$DATE.sql"

Запускати це можна з cron або GitHub Actions. Головне — не забути, що резервні копії теж потрібно видаляти після завершення строку зберігання.

Поведінка App при падінні зовнішніх сервісів

Резервні копії й PITR розвʼязують проблему «що робити, якщо все зовсім зламалося або дані зіпсовані». Але в бізнес‑реальності частіше трапляються часткові збої — упав зовнішній API, обрізало мережу, завис платіжний сервіс.

Коли OpenAI API або платіжний сервіс недоступні, найгірша стратегія — падати з «голим» 500 і безглуздим stack trace у відповіді. В ідеалі:

  • бекенд повертає структуровану помилку на кшталт { error: "upstream_unavailable" };
  • віджет показує людині зрозуміле повідомлення: «Сервіс тимчасово недоступний, спробуйте пізніше»;
  • система не продовжує нескінченно атакувати недоступний API повторними спробами (патерни Circuit Breaker тощо ми докладніше подивимося в модулі про стійкість).

Приклад обробника MCP‑tool, який враховує зовнішню помилку:

// mcp/tools/createGiftIdea.ts
export async function createGiftIdea(args: Input): Promise<Output> {
  try {
    return await callOpenAiModel(args);
  } catch (err) {
    await logAudit({
      actor: { userId: args.userId ?? null, client: "chatgpt-app" },
      action: "giftidea.generate",
      outcome: { status: "failure", reason: "openai_unavailable" },
    });
    throw new Error("UPSTREAM_UNAVAILABLE");
  }
}

Далі ваша прошарка між MCP і віджетом уже знає, як акуратно показати цю помилку в UI.

Перевірка відновлення: бекап без restore — просто файл

Класичний антипатерн: резервна копія створюється щодня, усі задоволені… доки не зʼясовується, що її неможливо відновити (формат змінився, ключ загублено, місця забракло).

Мінімальний план:

  • періодично (наприклад, раз на місяць) піднімати staging‑оточення з резервної копії;
  • проходити базові сценарії: вхід у систему, створення замовлення, робота застосунку;
  • переконуватися, що час відновлення та втрата даних вкладаються у ваші RTO/RPO.

Замість курсу з «релігії DevOps» у межах цієї лекції достатньо зрозуміти: процеси резервного копіювання — частина архітектури застосунку, а не «щось, що зробить хтось там у хмарі».

6. Візуалізація: життєвий цикл даних і подій

Щоб усе це було не лише у вигляді слів, намалюємо дві прості схеми.

Життєвий цикл даних користувача

flowchart TD
  A["Створення даних<br/>(реєстрація, замовлення)"] --> B["Зберігання та використання<br/>(prod DB)"]
  B --> C["Архів/агрегація<br/>(анонімні метрики)"]
  B --> D[Запит на видалення]
  D --> E[Видалення/анонімізація<br/>в prod DB]
  E --> F["Закінчення строку життя бекапів<br/>(retention)"]
    

Головна ідея: життєвий цикл даних не закінчується на прод‑БД — він продовжується і в резервних копіях.

Потік аудиту для небезпечної дії

sequenceDiagram
  participant User as Користувач
  participant ChatGPT as ChatGPT
  participant App as Ваш бекенд/MCP
  participant DB as База даних
  participant Audit as Сховище аудиту

  User->>ChatGPT: "Скасуй замовлення #123"
  ChatGPT->>App: callTool cancel_order
  App->>DB: UPDATE orders SET status='canceled'
  App->>Audit: INSERT audit_event {actor, action, resource, outcome}
  App-->>ChatGPT: Результат операції
  ChatGPT-->>User: Повідомлення про результат
    

7. Типові помилки в audit & lifecycle

Помилка №1: Змішування журналів аудиту й звичайних логів.
Коли всі повідомлення потрапляють до одного спільного logs‑index, за пів року ніхто не відрізнить «користувач змінив роль адміністратора» від «у нас знову null reference». В аудиті мають бути структуровані події бізнес‑рівня (див. розділ про структуру події аудиту) й окреме сховище з обмеженим доступом.

Помилка №2: Логування PII в аудиті та debug‑логах.
Повний email, телефон, адреса доставки, останні чотири цифри картки — усе це часто випадково опиняється в логах. Це підвищує ризик витоку й суперечить рекомендаціям щодо приватності. Натомість записуйте в журнали ідентифікатори та замасковані значення.

Помилка №3: Відсутність політики retention — «зберігаємо все завжди».
На етапі MVP це здається «ну й гаразд», а за рік ваші таблиці виростають до жахливих розмірів, і будь‑який запит в аналітику перетворюється на DDoS для самої БД. До того ж ви порушуєте принцип мінімізації, закладений у сучасних законах про дані. Мінімальні TTL за типами даних мають бути продумані, а чистка — автоматизована.

Помилка №4: «Видалення на запит» == DELETE FROM users.
Якщо ви просто видалили рядок користувача, але залишили його PII у замовленнях, сесіях і логах, то по суті нікого не видалили. Коректний підхід — у межах транзакції пройтися по всіх повʼязаних сутностях, а там, де видаляти не можна, — анонімізувати. І не забути залогувати сам факт видалення як audit‑подію.

Помилка №5: Ігнорування резервних копій під час видалення даних.
Видалили користувача в продакшені — добре, але його дані ще рік живуть у старих snapshots. Під час відновлення з них усе «воскресає», і ви знову порушуєте свої ж обіцянки користувачеві та в Privacy Policy. Потрібно або обмежувати строк життя резервних копій, або мати процедуру повторного застосування видалень після відновлення.

Помилка №6: «У нас увімкнено бекапи, значить усе добре», але ніхто не пробував відновитися.
Резервна копія, яку жодного разу не перевіряли на restore, — це просто дорогий файл. Без періодичної перевірки відновлення ви не знаєте ні фактичного RTO/RPO, ні того, чи працює взагалі ваш DR‑план. Мінімум — регулярне тестове підняття staging‑оточення з резервної копії за чек‑листом.

Помилка №7: Невідповідність між документацією та реальністю.
У Privacy Policy ви пишете, що зберігаєте логи 30 днів і видаляєте дані на запит, а в коді все залишається назавжди. Магазин ChatGPT, enterprise‑клієнти й аудитори легко це виявлять запитаннями на кшталт «покажіть таблицю retention» і «продемонструйте видалення конкретного користувача». Краще спочатку зробити, а потім написати.

1
Опитування
Безпека, рівень 15, лекція 4
Недоступний
Безпека
Безпека та відповідність
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ