IlmHamroh
JavaScript Full-stack/31-qism. Prodakshn backend va AI integratsiya23/34-dars27 daqiqa
Mundarija (27)

Audit log: kim, qachon, nimani — o'zgarmas jurnal va hash zanjiri

Qisqacha: Audit jurnali — muhim amallarni "kim, qachon, nimani" ko'rinishida o'zgarmas yozib boradigan jadval. Masalan, admin bloklashi, API kalit yaratish, hisob o'chirish. vazifalar-api da u ikki qatlam bilan himoyalangan: trigger UPDATE/DELETE/TRUNCATE ni taqiqlaydi, hash zanjiri esa trigger'ni chetlab o'tib qilingan tahrirni ham topadi. Nest tomonida handler'ga @Audit("admin.bloklash", "foydalanuvchi") yoziladi — global interceptor muvaffaqiyatli amalni jurnalga qo'shadi.

Bu darsda

  • Audit jurnali, oddiy log va o'zgarishlar tarixini farqlaysiz.
  • "Faqat qo'shiladi" jadvalni trigger bilan qurasiz va buzishga urinib ko'rasiz.
  • Hash zanjirini brauzerda modellaysiz va Postgres'da audit_tekshir() bilan buzilgan yozuvni topasiz.
  • @Audit dekoratori va global interceptor'ni loyihaga qo'shasiz.
  • Auditni amal bilan bitta tranzaksiyada yoki keyin yozishning narxini tushuntirasiz.

Oldin bilishingiz kerak: Soft delete, timestamps va tarix jadvallari, Triggerlar, Interceptor'lar va RxJS, So'rov konteksti: nestjs-cls, RBAC va resurs egaligi.

Oldingi darsda yumshoq o'chirishni ORM'da o'lchadik va loyihaga qo'shmadik. Bu darsda loyiha yana o'zgaradi: audit jurnali — keyingi ikki darsning birinchi yarmi. Ikkinchi yarmi (feature flag'lar) Feature flag'lar backend'da darsida, commit — o'sha yerda.

1. Nega bu kerak?

Dushanba ertalab Aziz ilovaga kira olmaydi: "hisob bloklangan". U yordamga yozadi. Kim blokladi? Qachon? Nega?

vazifalar-api da adminlar foydalanuvchini bloklay oladi (RBAC va resurs egaligi darsi). Lekin bu amalning izi qayerda? Server logida — ehtimol bor, lekin loglar bir necha kundan keyin aylanib o'chadi, ularda ko'p shovqin bor va ularni tahrirlash oson. auth_hodisalar jadvalida — kirish urinishlari, 180 kunda tozalanadi. Javobgarlik uchun boshqa narsa kerak: uzoq saqlanadigan, o'zgartirib bo'lmaydigan jurnal.

O'xshatish: notarius kitobi. Har amal sana va imzo bilan yoziladi. Varaqlar raqamlangan va tikilgan — bitta varaqni yirtib olsangiz, raqam uziladi. Bizning hash zanjirimiz — o'sha tikuv.

2. Audit, log va tarix

Uchala narsa ham "nima bo'ldi"ni yozadi, lekin har biri boshqa savolga javob beradi:

Oddiy log Tarix (vazifa_tarixi) Audit jurnali
Savol tizimda nima bo'ldi? ma'lumot qanday o'zgardi? kim qanday amal qildi?
Yozuvchi ilova (pino) trigger ilova (interceptor)
Muddat kunlar ma'lumot bilan yillar
Tahrir fayl — oson jadval — mumkin taqiqlangan + zanjir

Soft delete, timestamps va tarix jadvallari darsidagi vazifa_tarixi har vazifa o'zgarishini trigger bilan yozadi. U ko'p va mayda. Audit esa kam, lekin muhim: hisobga, ruxsatlarga, sozlamalarga tegadigan amallar.

Audit yozuvining klassik maydonlari:

  • aktyor (actor) — kim qildi: foydalanuvchi id'si;
  • amal (action) — nima qildi: admin.bloklash;
  • obyekt (entity) — nimaga: foydalanuvchi va uning id'si;
  • tafsilot — qo'shimcha: metod, yo'l, kerak bo'lsa "oldin/keyin" farqi;
  • vaqt va so'rov id'si — 25-qismdagi correlation ID bilan logdagi qatorga bog'lash uchun.

Tekshirib ko'ring: Nega muvaffaqiyatsiz urinishlar (403) audit jurnaliga yozilmaydi?

Javob

Audit — bajarilgan amal haqida. Urinishlar xavfsizlik hodisasi — ular auth_hodisalar da va logda. Agar har 403 auditga tushsa, hujumchi millionlab so'rov bilan jurnalni to'ldirib, muhim yozuvlarni "ko'mib" yuboradi. Kanon interceptor'i faqat 2xx javobdan keyin yozadi — testda buni tekshiramiz.

3. O'zgarmaslik: ikki qatlam

3.1 Birinchi qatlam — trigger taqiqi

Eng oddiy himoya — jadvalni "faqat qo'shiladi" (append-only) qilish. BEFORE UPDATE OR DELETE va BEFORE TRUNCATE trigger'lari xato tashlaydi. Triggerlar darsida trigger qatorni o'zgartirardi — bu yerda u amalni butunlay to'xtatadi.

Lekin superuser trigger'ni o'chira oladi (ALTER TABLE … DISABLE TRIGGER). Baza administratori, yoki parolini o'g'irlagan hujumchi, iz qoldirmasdan yozuvni o'zgartiradi. Ikkinchi qatlam shuning uchun kerak.

3.2 Ikkinchi qatlam — hash zanjiri

Har yozuvning hash maydoni = SHA-256(oldingi yozuvning hash'i + shu yozuvning maydonlari). Yozuvlar zanjirga bog'lanadi:

flowchart LR
  Y1["1-yozuv: hash1 = H(maydonlar1)"] --> Y2["2-yozuv: hash2 = H(hash1 + maydonlar2)"]
  Y2 --> Y3["3-yozuv: hash3 = H(hash2 + maydonlar3)"]
  Y3 --> T{"tekshiruv"}
  T --> A["hash qayta hisoblanadi"]
  T --> B["oldingi_hash = oldingi qatorning hash'imi?"]

Diagrammani o'qing. 1-yozuv o'zgartirilsa, uning qayta hisoblangan hash'i saqlangan hash ga mos kelmaydi. O'rtadagi yozuv o'chirilsa, keyingisining oldingi_hash i endi oldingi qatorga mos emas. Ikkalasini ham jim yashirish uchun hujumchi keyingi hamma yozuvlarni qayta hisoblashi kerak.

Brauzerda Web Crypto bilan modellaymiz (crypto 1: tasodif, hash va HMAC darsidagi SHA-256):

js
const hex = (b) => [...new Uint8Array(b)]
  .map((x) => x.toString(16).padStart(2, "0")).join("");
async function sha256(matn) {
  const b = new TextEncoder().encode(matn);
  return hex(await crypto.subtle.digest("SHA-256", b));
}
const satr = (y) => [y.id, y.aktyor, y.amal, y.obyektId].join("|");

async function yoz(jurnal, aktyor, amal, obyektId) {
  const oldingi = jurnal.at(-1)?.hash ?? "";
  const id = jurnal.length + 1;
  const y = { id, aktyor, amal, obyektId, oldingi };
  y.hash = await sha256(oldingi + satr(y));
  jurnal.push(y);
}

async function tekshir(jurnal) {
  const buzilgan = [];
  for (let i = 0; i < jurnal.length; i++) {
    const y = jurnal[i];
    const kutilgan = i === 0 ? "" : jurnal[i - 1].hash;
    if (y.oldingi !== kutilgan) {
      buzilgan.push(`${y.id}: zanjir uzilgan`);
    } else if (y.hash !== await sha256(y.oldingi + satr(y))) {
      buzilgan.push(`${y.id}: yozuv o'zgartirilgan`);
    }
  }
  return buzilgan.length === 0 ? "butun" : buzilgan.join("; ");
}

const jurnal = [];
await yoz(jurnal, 1, "admin.bloklash", "2");
await yoz(jurnal, 1, "api_kalit.yaratildi", null);
await yoz(jurnal, 1, "bayroq.ozgartirildi", "ai-qadamlar");
console.log("hash boshi:", jurnal[0].hash.slice(0, 16));
console.log("1)", await tekshir(jurnal));
jurnal[0].aktyor = 7; // kimdir aktyorni almashtirdi
console.log("2)", await tekshir(jurnal));
jurnal[0].aktyor = 1;
jurnal.splice(1, 1); // o'rtadagi yozuv o'chirildi
console.log("3)", await tekshir(jurnal));

Konsolda:

text
hash boshi: a6794e32895d55fb
1) butun
2) 1: yozuv o'zgartirilgan
3) 3: zanjir uzilgan

Uchta holat: zanjir butun; birinchi yozuvdagi aktyor almashtirildi — hash mos emas; ikkinchi yozuv o'chirildi — uchinchisining oldingi maydoni endi birinchisiga mos emas. Kodda null qiymat join da bo'sh satrga aylanadi — Postgres'dagi concat_ws ham NULL ni tashlab ketadi, g'oya bir xil.

Diqqat: Hash zanjiri aniqlaydi, lekin oldini olmaydi. Bazaga to'liq kirgan hujumchi butun zanjirni qayta hisoblab yozishi mumkin — kalit yo'q, SHA-256 ochiq algoritm. Bunga qarshi oxirgi hash'ni vaqti-vaqti bilan tashqariga — boshqa tizimga, faqat yoziladigan xotiraga yoki imzolab — chiqarib qo'yish kerak. Kanon buni qilmagan (sinalmagan).

4. Loyiha qadami: audit jurnali

4.1 Qaysi fayllar

Fayl Nima
sql/305-audit.sql audit sxemasi, jadval, audit_yoz(), audit_tekshir(), trigger'lar, huquqlar
prisma/migrations/20261102145000_audit/migration.sql sql/305 bilan aynan
src/audit/audit.ts @Audit, AuditInterceptor, AuditController
src/app.module.ts interceptor va controller ulanadi
src/auth/admin.controller.ts, auth.controller.ts amallar belgilanadi
test/prodakshn.test.ts audit testlari

Kanon fayllarida qatorlar uzun edi — telefon uchun bo'ldik, izohlar qisqartirildi, kod ma'nosi aynan. Bo'lingan nusxani loyihaning shu bosqichida tsc --noEmit, ESLint va Vitest bilan tekshirdik — o'zgarishsiz o'tdi.

4.2 sql/305-audit.sql

Faylni o'z bazamizda (PostgreSQL 17.6, oldingi sql/ fayllari qo'llangan) ishga tushirdik:

sql
-- 305-audit.sql — audit jurnali: KIM, QACHON, NIMANI (admin amallari,
-- hisob o'chirish, API kalitlar, bayroqlar).
-- vazifa_tarixi (010) — ma'lumot o'zgarishi; auth_hodisalar (111) —
-- xavfsizlik hodisalari (180 kun); audit — javobgarlik: o'zgarmas
-- va uzoq saqlanadi. O'zgarmaslik: (1) faqat qo'shiladi —
-- UPDATE/DELETE/TRUNCATE trigger bilan taqiqlangan (superuser ham —
-- trigger'ni ataylab o'chirmasa); (2) hash zanjiri: hash =
-- sha256(oldingi_hash || maydonlar) — trigger o'chirilib qator
-- o'zgartirilsa ham audit_tekshir() uzilgan joyni topadi.
-- Alohida sxema `audit`: Prisma (public) va testlardagi TRUNCATE
-- unga tegmaydi; ilova rollariga — faqat audit_yoz() funksiyasi.
CREATE SCHEMA audit;

CREATE TABLE audit.jurnal (
  id           bigint      GENERATED ALWAYS AS IDENTITY,
  vaqt         timestamptz NOT NULL,
  -- FK yo'q: aktyor hisobi o'chirilsa (302) ham yozuv qoladi
  aktyor_id    bigint,
  amal         text        NOT NULL,
  obyekt       text,
  obyekt_id    text,
  tafsilot     jsonb       NOT NULL DEFAULT '{}'::jsonb,
  sorov_id     text,
  oldingi_hash bytea,
  hash         bytea       NOT NULL,
  CONSTRAINT jurnal_pkey PRIMARY KEY (id),
  CONSTRAINT jurnal_amal_check
    CHECK (amal ~ '^[a-z][a-z0-9_.-]{1,59}$')
);
CREATE INDEX jurnal_aktyor_vaqt ON audit.jurnal (aktyor_id, vaqt);
CREATE INDEX jurnal_obyekt ON audit.jurnal (obyekt, obyekt_id);

-- hash qilinadigan kanonik satr (tekshiruv ham AYNAN shundan
-- foydalanadi)
CREATE FUNCTION audit.satr(j audit.jurnal) RETURNS bytea
LANGUAGE sql IMMUTABLE
RETURN convert_to(concat_ws('|', j.id,
  to_char(j.vaqt AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US'),
  j.aktyor_id, j.amal, j.obyekt, j.obyekt_id, j.tafsilot::text,
  j.sorov_id), 'UTF8');

CREATE FUNCTION audit_yoz(aktyor bigint, amal text, obyekt text,
  obyekt_id text, tafsilot jsonb, sorov_id text) RETURNS bigint
LANGUAGE plpgsql SECURITY DEFINER
SET search_path = pg_catalog, public, audit AS $$
DECLARE oldingi bytea; j audit.jurnal;
BEGIN
  -- zanjir tartibi: bir vaqtda ikki yozuv bir xil "oldingi"ni
  -- olmasin (tranzaksiya oxirigacha qulf)
  PERFORM pg_advisory_xact_lock(hashtext('audit.jurnal'));
  SELECT hash INTO oldingi FROM audit.jurnal ORDER BY id DESC LIMIT 1;
  j.id := nextval(pg_get_serial_sequence('audit.jurnal', 'id'));
  j.vaqt := date_trunc('microseconds', clock_timestamp());
  j.aktyor_id := aktyor; j.amal := amal; j.obyekt := obyekt;
  j.obyekt_id := obyekt_id; j.tafsilot := coalesce(tafsilot, '{}');
  j.sorov_id := sorov_id;
  j.oldingi_hash := oldingi;
  j.hash := sha256(coalesce(oldingi, '\x'::bytea) || audit.satr(j));
  INSERT INTO audit.jurnal OVERRIDING SYSTEM VALUE SELECT j.*;
  RETURN j.id;
END $$;

-- zanjirni tekshirish: buzilgan yozuvlar (bo'sh natija — butun)
CREATE FUNCTION audit_tekshir() RETURNS TABLE (id bigint, sabab text)
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path = pg_catalog, public, audit AS $$
  SELECT x.id, x.sabab FROM (
    SELECT j.id,
      CASE
        WHEN j.oldingi_hash IS DISTINCT FROM
             lag(j.hash) OVER (ORDER BY j.id)
          THEN 'oldingi_hash zanjirga mos emas '
            || '(yozuv o''chirilgan yoki qo''shilgan)'
        WHEN j.hash <> sha256(coalesce(j.oldingi_hash, '\x'::bytea)
                              || audit.satr(j))
          THEN 'hash mos emas (yozuv o''zgartirilgan)'
      END AS sabab
    FROM audit.jurnal j) x
  WHERE x.sabab IS NOT NULL ORDER BY x.id
$$;

CREATE FUNCTION audit.ozgartirish_taqiq() RETURNS trigger
LANGUAGE plpgsql AS $$ BEGIN
  RAISE EXCEPTION 'audit.jurnal faqat qo''shiladi (%)', TG_OP
    USING ERRCODE = '42501';
END $$;
CREATE TRIGGER jurnal_ozgarmas BEFORE UPDATE OR DELETE ON audit.jurnal
  FOR EACH ROW EXECUTE FUNCTION audit.ozgartirish_taqiq();
CREATE TRIGGER jurnal_ozgarmas_truncate
  BEFORE TRUNCATE ON audit.jurnal
  FOR EACH STATEMENT EXECUTE FUNCTION audit.ozgartirish_taqiq();

REVOKE ALL ON SCHEMA audit FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION
  audit_yoz(bigint, text, text, text, jsonb, text), audit_tekshir()
  FROM PUBLIC;
GRANT EXECUTE ON FUNCTION
  audit_yoz(bigint, text, text, text, jsonb, text)
  TO vazifalar_ilova, vazifalar_auth;
GRANT EXECUTE ON FUNCTION audit_tekshir() TO vazifalar_auth;
-- admin ro'yxati (GET /v1/admin/audit) — auth roli o'qiydi
GRANT USAGE ON SCHEMA audit TO vazifalar_auth;
GRANT SELECT ON audit.jurnal TO vazifalar_auth;

COMMENT ON TABLE audit.jurnal IS
  'audit jurnali — faqat qo''shiladi (trigger), '
  'hash zanjiri (audit_tekshir)';
text
CREATE SCHEMA
CREATE TABLE
CREATE INDEX
CREATE INDEX
CREATE FUNCTION
CREATE FUNCTION
CREATE FUNCTION
CREATE FUNCTION
CREATE TRIGGER
CREATE TRIGGER
REVOKE
REVOKE
GRANT
GRANT
GRANT
GRANT
COMMENT

Muhim joylar:

  • Alohida sxema audit. Testlardagi tozala() public jadvallarini TRUNCATE qiladi, Prisma ham faqat public ni ko'radi. Audit ulardan ajratilgan.
  • aktyor_id — tashqi kalit EMAS. Hisob o'chirilsa (Ma'lumot saqlash muddati darsidagi anonimlashtirish), yozuv qolishi kerak. Tarix jadvalidagi qoida bilan bir xil.
  • audit_yoz() — SECURITY DEFINER. Ilova rollari jadvalga to'g'ridan-to'g'ri yozolmaydi, faqat funksiyani chaqiradi. Funksiya egasining huquqi bilan ishlaydi; search_path qotirilgan — begona sxemadan soxta sha256 chaqirilmaydi.
  • pg_advisory_xact_lock. Ikki so'rov bir vaqtda yozsa, ikkalasi bir xil "oxirgi hash"ni o'qib, zanjirni ikkiga ayirardi. Qulf yozuvlarni navbatga qo'yadi — tranzaksiya oxirigacha. Narxi: audit yozuvi ketma-ket. Kam yoziladigan jadval uchun bu maqbul.
  • date_trunc('microseconds', …). Hash'ga kiradigan vaqt bazada qanday saqlansa, shunday bo'lishi kerak — aks holda tekshiruv yolg'on "buzilgan" deydi.
  • audit.satr() — kanonik satr bitta funksiyada. Yozish ham, tekshirish ham uni chaqiradi — format farqi chiqmaydi.

Migratsiya fayli prisma/migrations/20261102145000_audit/migration.sql — shu matnning aynan nusxasi. audit sxemasi Prisma sxemasiga kirmaydi (Prisma faqat public ni boshqaradi), shuning uchun schema.prisma o'zgarmaydi.

4.3 Bazada sinab ko'ramiz

Uchta yozuv:

sql
SELECT audit_yoz(NULL, 'admin.bloklash', 'foydalanuvchi', '2',
  '{"metod":"POST"}', 's-1') AS id;
SELECT audit_yoz(1, 'api_kalit.yaratildi', 'api_kalit', NULL,
  '{}', 's-2') AS id;
SELECT audit_yoz(1, 'bayroq.ozgartirildi', 'bayroq', 'ai-qadamlar',
  '{}', 's-3') AS id;
SELECT id, amal, length(hash) AS hash_bayt,
       oldingi_hash IS NULL AS zanjir_boshi
FROM audit.jurnal ORDER BY id;
SELECT count(*) AS buzilgan FROM audit_tekshir();
text
 id
----
  1
(1 row)

 id
----
  2
(1 row)

 id
----
  3
(1 row)

 id |        amal         | hash_bayt | zanjir_boshi
----+---------------------+-----------+--------------
  1 | admin.bloklash      |        32 | t
  2 | api_kalit.yaratildi |        32 | f
  3 | bayroq.ozgartirildi |        32 | f
(3 rows)

 buzilgan
----------
        0
(1 row)

Hash 32 bayt (SHA-256). Faqat birinchi yozuvda oldingi_hash bo'sh — zanjir boshi. Hash qiymatini o'zini ko'rsatmadik: unga clock_timestamp() kiradi, har yugurishda boshqacha.

Endi o'zgartirishga urinamiz:

sql
UPDATE audit.jurnal SET amal = 'yashirin.amal' WHERE id = 2;
TRUNCATE audit.jurnal;
text
ERROR:  audit.jurnal faqat qo'shiladi (UPDATE)
CONTEXT:  PL/pgSQL function audit.ozgartirish_taqiq() line 2 at RAISE
ERROR:  audit.jurnal faqat qo'shiladi (TRUNCATE)
CONTEXT:  PL/pgSQL function audit.ozgartirish_taqiq() line 2 at RAISE

Biz postgres (superuser) roli bilan ulandik — trigger superuser'ni ham to'xtatdi. CONTEXT qatori xato qaysi funksiyaning qaysi qatorida chiqqanini aytadi.

Endi "yovuz administrator": trigger'ni o'chiradi, birinchi yozuvni tahrirlaydi, ikkinchisini o'chiradi va izni yopish uchun trigger'ni qaytaradi:

sql
ALTER TABLE audit.jurnal DISABLE TRIGGER jurnal_ozgarmas;
UPDATE audit.jurnal SET tafsilot = '{"metod":"GET"}' WHERE id = 1;
DELETE FROM audit.jurnal WHERE id = 2;
ALTER TABLE audit.jurnal ENABLE TRIGGER jurnal_ozgarmas;
SELECT * FROM audit_tekshir();
text
ALTER TABLE
UPDATE 1
DELETE 1
ALTER TABLE
 id |                               sabab
----+--------------------------------------------------------------------
  1 | hash mos emas (yozuv o'zgartirilgan)
  3 | oldingi_hash zanjirga mos emas (yozuv o'chirilgan yoki qo'shilgan)
(2 rows)

Ikkala buzilish topildi. 1-yozuvning maydoni o'zgargan — hash mos emas. 2-yozuv yo'q — 3-yozuvning oldingi_hash i endi 1-yozuvning hash'iga mos kelmaydi. Kanon ham qo'lda sinovda aynan shu natijani olgan: "trigger o'chirib tahrir + DELETE → 2 buzilish topildi".

Oxirgi tekshiruv — ilova rollari:

sql
SET ROLE vazifalar_ilova;
SELECT audit_yoz(NULL, 'sinov.amal', NULL, NULL, '{}', NULL) > 0
  AS yozdi;
SELECT count(*) FROM audit.jurnal;
text
SET
 yozdi
-------
 t
(1 row)

ERROR:  permission denied for schema audit
LINE 1: SELECT count(*) FROM audit.jurnal;
                             ^

Ilova roli jurnalga funksiya orqali yoza oladi, lekin jadvalni o'qiy ham, o'zgartira ham olmaydi. O'qish — faqat vazifalar_auth (admin yo'li) uchun. API'ning asosiy roli vazifalar_api vazifalar_ilova ning a'zosi, shuning uchun u ham shunday cheklangan.

4.4 src/audit/audit.ts

Endi Nest tomoni. Yangi fayl to'liq:

ts
// audit.ts — audit jurnali Nest'da. `@Audit("amal", "obyekt")` —
// handler muvaffaqiyatli tugagach (2xx) global AuditInterceptor
// `audit_yoz()` ga yozadi (sql/305: faqat qo'shiladi, hash zanjiri).
// Aktyor — joriy foydalanuvchi (sessiya yoki API kalit), obyekt_id —
// `:id` parametri, sorov_id — correlation ID (25-qism).
// Muvaffaqiyatsiz so'rov (4xx/5xx) yozilmaydi — urinishlar
// `auth_hodisalar` da. Yozish amaldan KEYIN, alohida tranzaksiyada:
// amal bajarilib audit yozilmasa — error log. Qat'iy talab bo'lsa —
// amal bilan bitta tranzaksiyada (darsda).
import {
  Controller, Get, HttpCode, Inject, Injectable, Post, Query,
  SetMetadata,
} from "@nestjs/common";
import type {
  CallHandler, ExecutionContext, NestInterceptor,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import type { Request } from "express";
import { from, mergeMap, Observable } from "rxjs";
import { z } from "zod";
import { AUTH, LOG } from "../tokenlar.ts";
import type { AuthBogliqliklari } from "../auth/xizmat.ts";
import { Rollar } from "../auth/qoriqchi.ts";
import { joriyLog } from "../umumiy/log.ts";
import type { Log } from "../umumiy/log.ts";

const AUDIT = "audit:amal";
export type AuditBelgisi = { amal: string; obyekt?: string };
/** @Audit("admin.bloklash", "foydalanuvchi") — muvaffaqiyatli
 * bajarilsa audit jurnaliga */
export const Audit = (
  amal: string, obyekt?: string,
): MethodDecorator =>
  SetMetadata(AUDIT, {
    amal, ...(obyekt === undefined ? {} : { obyekt }),
  } satisfies AuditBelgisi);

export async function auditYoz(
  pool: AuthBogliqliklari["pool"],
  y: {
    aktyor: number | null; amal: string; obyekt?: string | null;
    obyektId?: string | null; tafsilot?: Record<string, unknown>;
    sorovId?: string | null;
  },
): Promise<number> {
  const { rows } = await pool.query<{ id: string }>(
    "SELECT audit_yoz($1, $2, $3, $4, $5, $6) AS id",
    [y.aktyor, y.amal, y.obyekt ?? null, y.obyektId ?? null,
      y.tafsilot ?? {}, y.sorovId ?? null]);
  return Number(rows[0]?.id);
}

@Injectable()
export class AuditInterceptor implements NestInterceptor {
  constructor(
    private readonly reflector: Reflector,
    @Inject(AUTH) private readonly auth: AuthBogliqliklari | null,
    @Inject(LOG) private readonly log: Log,
  ) {}

  intercept(k: ExecutionContext, keyingi: CallHandler):
    Observable<unknown> {
    const b = this.reflector.get<AuditBelgisi | undefined>(
      AUDIT, k.getHandler());
    if (b === undefined || this.auth === null
      || k.getType() !== "http") {
      return keyingi.handle();
    }
    const req = k.switchToHttp().getRequest<Request>();
    const pool = this.auth.pool;
    // aktyor so'rov BOSHIDA olinadi: DELETE /v1/auth/men dan keyin
    // sessiya yo'q bo'ladi
    const aktyor = req.egasi ?? null;
    return keyingi.handle().pipe(mergeMap((javob: unknown) =>
      from((async () => {
        try {
          // obyekt identifikatori — yo'l parametri (:id yoki :kalit)
          const id = req.params["id"] ?? req.params["kalit"];
          await auditYoz(pool, {
            aktyor, amal: b.amal, obyekt: b.obyekt ?? null,
            obyektId: typeof id === "string" ? id : null,
            sorovId: req.sorovId,
            tafsilot: {
              metod: req.method,
              yol: req.originalUrl.split("?")[0] ?? "",
            },
          });
        } catch (err) {
          joriyLog(this.log).error(
            { err, audit: b.amal }, "audit yozilmadi");
        }
        return javob;
      })())));
  }
}

const Royxat = z.strictObject({
  keyin: z.coerce.number().int().min(0).default(0),
  limit: z.coerce.number().int().min(1).max(100).default(50),
});

/** GET /v1/admin/audit — jurnal (keyset),
 * POST /v1/admin/audit/tekshir — hash zanjiri butunligi */
@Controller("admin/audit")
@Rollar("admin")
export class AuditController {
  constructor(
    @Inject(AUTH) private readonly auth: AuthBogliqliklari,
  ) {}

  @Get()
  async royxat(
    @Query({ schema: Royxat }) q: z.output<typeof Royxat>,
  ): Promise<object> {
    const { rows } = await this.auth.pool.query<
      Record<string, unknown>>(
      `SELECT id::int, vaqt, aktyor_id::int AS "aktyorId", amal,
              obyekt, obyekt_id AS "obyektId", tafsilot,
              sorov_id AS "sorovId", encode(hash, 'hex') AS hash
         FROM audit.jurnal WHERE id > $1 ORDER BY id LIMIT $2`,
      [q.keyin, q.limit]);
    return {
      yozuvlar: rows,
      keyingi: rows.length === q.limit ? rows.at(-1)?.["id"] : null,
    };
  }

  @Post("tekshir")
  @HttpCode(200)
  async tekshir(): Promise<object> {
    const { rows } = await this.auth.pool.query<
      { id: string; sabab: string }>(
      "SELECT id, sabab FROM audit_tekshir()");
    const { rows: [n] } = await this.auth.pool.query<{ n: number }>(
      "SELECT count(*)::int AS n FROM audit.jurnal");
    return {
      butun: rows.length === 0,
      yozuvlar: n?.n ?? 0,
      buzilganlar: rows.map((r) => ({
        id: Number(r.id), sabab: r.sabab,
      })),
    };
  }
}

Fayldagi uch qism:

  1. @Audit(amal, obyekt) — Guard'lar darsidagi @Rollar kabi oddiy metama'lumot dekoratori (SetMetadata). U hech narsa qilmaydi — faqat handler'ga belgi qo'yadi.
  2. AuditInterceptor — Interceptor'lar va RxJS darsidagi naqsh. Belgi bo'lmasa — o'tkazib yuboradi. Bo'lsa — handler tugaguncha kutadi (mergeMap), keyin audit_yoz() ni chaqiradi va asl javobni qaytaradi. Handler xato tashlasa, mergeMap chaqirilmaydi — muvaffaqiyatsiz amal yozilmaydi.
  3. AuditController — admin uchun ro'yxat (keyset sahifalash, Keyset pagination) va zanjir tekshiruvi. Kanon izohida tekshiruv yo'li GET deb yozilgan, kodda esa @Post("tekshir") — izohni kodga moslab tuzatdik.

Aktyor so'rov boshida olinadi (req.egasi). Nega? DELETE /v1/auth/men dan keyin sessiya yo'q bo'ladi — handler tugagach aktyorni olishga urinsak, null chiqardi.

4.5 Ulash va belgilash

src/app.module.ts ga (faqat o'zgargan qatorlar):

ts
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE }
  from "@nestjs/core";
import { AuditController, AuditInterceptor } from "./audit/audit.ts";

// ilova modulining ichida:
// 31: admin audit jurnali (auth bilan)
controllers: authYoqilgan
  ? [IchkiController, AuditController]
  : [IchkiController],
providers: [
  // ... mavjud guard'lar va filtr
  // @Audit(...) belgilangan handler muvaffaqiyatli tugasa — jurnalga
  { provide: APP_INTERCEPTOR, useClass: AuditInterceptor },
],

Belgilanadigan amallar — admin.controller.ts da:

ts
import { Audit } from "../audit/audit.ts";

@Post("foydalanuvchilar/:id/bloklash")
@Audit("admin.bloklash", "foydalanuvchi")
@HttpCode(204)
async bloklash(/* ... */) {}

@Post("foydalanuvchilar/:id/blokdan-chiqarish")
@Audit("admin.blokdan_chiqarish", "foydalanuvchi")
@HttpCode(204)
async blokdanChiqarish(/* ... */) {}

auth.controller.ts da (Audit importi ham qo'shiladi):

ts
@Delete("men")
@Audit("hisob.ochirildi", "foydalanuvchi")

@Post("api-kalitlar")
@Audit("api_kalit.yaratildi", "api_kalit")

@Delete("api-kalitlar/:id")
@Audit("api_kalit.bekor", "api_kalit")

Keyingi darsda bayroqlarni o'zgartirish ham @Audit("bayroq.ozgartirildi", "bayroq") bilan belgilanadi.

4.6 Test

test/prodakshn.test.ts — hozircha audit qismi (keyingi darsda bayroq testlari qo'shiladi):

ts
// prodakshn.test.ts — audit jurnali (sql/305: faqat qo'shiladi,
// hash zanjiri). Auth test bazasi kerak; bo'lmasa SKIP.
import { afterAll, beforeEach, test } from "vitest";
import assert from "node:assert/strict";
import request from "supertest";
import { expressIlova } from "./yordamchi.ts";
import { PgOmbori } from "../src/vazifalar/pg-ombor.ts";
import { SoxtaProvayder } from "../src/ai/soxta.ts";
import {
  authBaza, authBogliqliklari, hisobOch, PAROL, tozala,
} from "./auth-yordam.ts";

const baza = await authBaza("prodakshn");
if (typeof baza === "string") {
  test.skip(`audit (Postgres) — ${baza}`, () => {});
} else {
  const pool = baza;
  afterAll(() => pool.end());
  beforeEach(() => tozala(pool));
  const tayyorla = async () => {
    const d = authBogliqliklari(pool);
    const p = new SoxtaProvayder({
      javob: () => JSON.stringify({
        qadamlar: [
          { matn: "a", daqiqa: 1 }, { matn: "b", daqiqa: 2 },
        ],
      }),
    });
    const app = await expressIlova({
      ombor: (e) => new PgOmbori(pool, e), auth: d,
      ai: { pool, provayder: p },
    });
    const admin = await hisobOch(app, d, "bekzod@example.com");
    const aziz = await hisobOch(app, d, "aziz@example.com");
    await pool.query("UPDATE foydalanuvchilar SET rol = 'admin' "
      + "WHERE email = 'bekzod@example.com'");
    const id = async (email: string) => Number((await pool.query<
      { id: string }>(
      "SELECT id FROM foydalanuvchilar WHERE email = $1",
      [email])).rows[0]?.id);
    return {
      app, admin, aziz,
      adminId: await id("bekzod@example.com"),
      azizId: await id("aziz@example.com"),
    };
  };
  const jurnal = async () => (await pool.query<{
    aktyor_id: string | null; amal: string; obyekt_id: string | null;
  }>("SELECT aktyor_id, amal, obyekt_id FROM audit.jurnal "
    + "ORDER BY id")).rows;

  test("#23 audit: muvaffaqiyatli amal yoziladi, 403 — yo'q; "
    + "zanjir butun → buzilsa topiladi", async () => {
    const { app, admin, aziz, adminId, azizId } = await tayyorla();
    const A = "/v1/admin/foydalanuvchilar";
    const oldin = (await jurnal()).length;
    await request(app).post(`${A}/${String(adminId)}/bloklash`)
      .set("Cookie", aziz).expect(403);
    await request(app).post(`${A}/${String(azizId)}/bloklash`)
      .set("Cookie", admin).expect(204);
    await request(app)
      .post(`${A}/${String(azizId)}/blokdan-chiqarish`)
      .set("Cookie", admin).expect(204);
    await request(app).post("/v1/auth/api-kalitlar")
      .set("Cookie", admin)
      .send({ nom: "ci", ruxsatlar: ["vazifalar:oqish"] })
      .expect(201);
    const j = (await jurnal()).slice(oldin);
    assert.deepEqual(j.map((x) => [x.amal,
      x.aktyor_id === null ? null : Number(x.aktyor_id),
      x.obyekt_id]), [
      ["admin.bloklash", adminId, String(azizId)],
      ["admin.blokdan_chiqarish", adminId, String(azizId)],
      ["api_kalit.yaratildi", adminId, null],
    ]);
    const l = (await request(app).get("/v1/admin/audit")
      .set("Cookie", admin).expect(200)).body as {
      yozuvlar: { amal: string; hash: string }[];
    };
    assert.match(l.yozuvlar.at(-1)?.hash ?? "", /^[0-9a-f]{64}$/);
    // bloklash Azizning HAMMA sessiyalarini bekor qilgan
    await request(app).get("/v1/admin/audit").set("Cookie", aziz)
      .expect(401);
    const t0 = await request(app).post("/v1/admin/audit/tekshir")
      .set("Cookie", admin).expect(200);
    assert.deepEqual(t0.body,
      { butun: true, yozuvlar: j.length + oldin, buzilganlar: [] });
    await assert.rejects(
      pool.query("UPDATE audit.jurnal SET amal = 'yashirin.amal'"),
      /faqat qo'shiladi/);
    await assert.rejects(pool.query("DELETE FROM audit.jurnal"),
      /faqat qo'shiladi/);
    // "superuser trigger'ni o'chirib tahrirladi" — zanjir ko'rsatadi
    await pool.query(
      "ALTER TABLE audit.jurnal DISABLE TRIGGER jurnal_ozgarmas");
    const birinchi = (await pool.query<{ id: string }>(
      "SELECT min(id) AS id FROM audit.jurnal "
      + "WHERE amal = 'admin.bloklash'")).rows[0]?.id;
    await pool.query("UPDATE audit.jurnal SET tafsilot = "
      + "'{\"metod\":\"GET\"}' WHERE id = $1", [birinchi]);
    await pool.query(
      "ALTER TABLE audit.jurnal ENABLE TRIGGER jurnal_ozgarmas");
    const t = (await request(app).post("/v1/admin/audit/tekshir")
      .set("Cookie", admin).expect(200)).body as {
      butun: boolean; buzilganlar: { id: number; sabab: string }[];
    };
    assert.equal(t.butun, false);
    assert.deepEqual(t.buzilganlar.map((x) => x.id),
      [Number(birinchi)]);
  });

  test("#23 audit: hisob o'chirish — aktyor o'zi "
    + "(amaldan keyin sessiya yo'q bo'lsa ham)", async () => {
    const { app, aziz, azizId } = await tayyorla();
    await request(app).delete("/v1/auth/men").set("Cookie", aziz)
      .send({ parol: PAROL }).expect(204);
    const j = (await jurnal())
      .filter((x) => x.amal === "hisob.ochirildi");
    assert.deepEqual(j.map((x) => Number(x.aktyor_id)), [azizId]);
  });
}

Biz loyihaning shu bosqichini PostgreSQL 17.6 da ishga tushirdik (test bazasi — sql/ fayllari, namunasiz):

text
 ✓ |asosiy| test/prodakshn.test.ts (2 tests) 2636ms
   ✓ #23 audit: muvaffaqiyatli amal yoziladi, 403 — yo'q; zanjir butun → buzilsa topiladi 1945ms
   ✓ #23 audit: hisob o'chirish — aktyor o'zi (amaldan keyin sessiya yo'q bo'lsa ham) 686ms

Vaqtlar mashinaga bog'liq. Birinchi test bitta oqimda hamma narsani tekshiradi. 403 yozilmaydi, uchta muvaffaqiyatli amal aktyor va obyekt bilan yoziladi. Ro'yxatdagi hash — 64 ta o'n oltilik belgi, zanjir butun. UPDATE/DELETE rad etiladi, trigger o'chirilgan tahrir esa aniq id bilan topiladi.

Testdagi kutilmagan, lekin to'g'ri qator: Aziz bloklangandan keyin uning cookie'si bilan GET /v1/admin/audit — 401, 403 emas. Bloklash Azizning hamma sessiyalarini bekor qilgan, blokdan chiqarish ularni qaytarmaydi.

Hali commit qilmang. Loyihaning shu bosqichi keyingi darsda feature flag'lar bilan birga bitta commit bo'ladi.

5. Halol kamchiliklar va qarorlar

5.1 Amaldan keyin, alohida tranzaksiyada

Interceptor auditni amal tugagandan keyin, alohida so'rovda yozadi. Ikki yo'lni solishtiramiz:

Yo'l Afzallik Kamchilik
Keyin, alohida (kanon) amal kodi o'zgarmaydi, bitta dekorator amal bo'ldi, audit yiqildi — iz yo'q (faqat error log)
Amal bilan bitta tranzaksiyada ikkalasi yoki hech biri har handler tranzaksiyani audit bilan bo'lishishi kerak

Kanon birinchisini tanladi va bu xavfni ochiq yozib qo'ydi: audit yozilmasa — error darajasidagi log ("audit yozilmadi"), monitoring uni ko'radi. Pul o'tkazmasi kabi qat'iy talabda ikkinchi yo'l kerak — 29-qismdagi outbox g'oyasiga o'xshash: amal va uning izi bitta tranzaksiyada (Transactional outbox naqshi).

5.2 Yaratilgan obyektning id'si yo'q

Testdagi uchinchi yozuvga qarang: ["api_kalit.yaratildi", adminId, null]. Obyekt id'si yo'l parametridan (:id yoki :kalit) olinadi. Yaratish yo'lida parametr yo'q — yangi kalitning id'si jurnalga tushmaydi. Kim yaratgani va qachon — bor, lekin aynan qaysi kalit — yo'q. Tuzatish varianti: javob tanasidan id'ni olish (javob.id). Kanon buni qilmagan — buni 2-mashqda o'zingiz o'ylab ko'rasiz.

5.3 Saqlash muddati va shaxsiy ma'lumot

Audit uzoq saqlanadi, lekin abadiy emas. Ma'lumot saqlash muddati darsidagi anonimlashtirish foydalanuvchi qatorini tozalaydi, audit esa qoladi: unda aktyor_id raqami bor, ism va email yo'q. Shuning uchun tafsilot ga shaxsiy ma'lumot (email, parol, token) yozmang — kanon faqat metod va yo'lni yozadi. Jurnalni arxivlash yoki o'chirish kerak bo'lsa — bu TRUNCATE emas, alohida, auditlangan protsedura bo'ladi (sinalmagan).

5.4 Interceptor yoki trigger?

Auditni bazada trigger bilan ham yozish mumkin — Triggerlar darsidagi tarix jadvali shunday ishlaydi. Ikki yo'lning kuchli tomonlari har xil.

Trigger har o'zgarishni ushlaydi, hatto ilovani chetlab o'tgan qo'lda yozilgan SQL'ni ham. Lekin u biznes amalini bilmaydi. U "foydalanuvchilar jadvalida bloklangan_vaqt o'zgardi" deydi, "admin Aziz'ni blokladi" demaydi. Aktyorni esa faqat ilova bergan kontekstdan (SET LOCAL) oladi — ilova bermasa, aktyor noma'lum.

Interceptor amal nomini va aktyorni aniq biladi. Lekin u faqat ilova orqali o'tgan amallarni ko'radi: kimdir bazaga to'g'ridan-to'g'ri ulanib o'zgartirsa, interceptor buni bilmaydi.

Kanon ikkalasini birga ishlatadi: ma'lumot o'zgarishi — vazifa_tarixi trigger'i, javobgarlik amallari — interceptor va hash zanjirli jurnal. Bazaga to'g'ridan-to'g'ri kirishni esa rollar cheklaydi: ilova rollari faqat o'z funksiyalarini chaqira oladi.

6. Hujumchi nigohi

Audit jurnali — hujumchi uchun "izni yo'qotish" nishoni. U bazaga kirgach, birinchi navbatda o'z amallarini jurnaldan o'chirishga urinadi. Uning yo'llarini birma-bir ko'ramiz.

1. Trigger'ni o'chirmasdan chetlab o'tish. ALTER TABLE … DISABLE TRIGGER katalogda iz qoldiradi va admin buni sezishi mumkin. Superuser'ning jimroq yo'li bor — session_replication_role sozlamasi. Uni replica ga o'rnatsangiz, oddiy trigger'lar faqat shu sessiyada ishlamaydi. Asli bu sozlama mantiqiy replikatsiya uchun: replikaga kelgan o'zgarishlarda trigger'lar ikkinchi marta ishlamasin. Sinab ko'ramiz — 4-yozuv ilova roli yozgan oxirgi qator:

sql
SET session_replication_role = replica;
UPDATE audit.jurnal SET amal = 'sinov.yashirin' WHERE id = 4;
RESET session_replication_role;
SELECT * FROM audit_tekshir();
text
SET
UPDATE 1
RESET
 id |                               sabab
----+--------------------------------------------------------------------
  1 | hash mos emas (yozuv o'zgartirilgan)
  3 | oldingi_hash zanjirga mos emas (yozuv o'chirilgan yoki qo'shilgan)
  4 | hash mos emas (yozuv o'zgartirilgan)
(3 rows)

Nima bo'ldi? UPDATE 1 — trigger jim qoldi, tahrir o'tdi. ALTER TABLE ham bo'lmadi. Lekin hash zanjiri buni ham ushladi: 4-yozuv yangi qatorda paydo bo'ldi. Ikki qatlam nega kerakligini shu ko'rsatadi: trigger'ni chetlab o'tishning yo'li ko'p, zanjirni esa yolg'on qiymat bilan "aldab" bo'lmaydi.

2. Butun zanjirni qayta hisoblash. Bu yo'l haqida yuqoridagi «Diqqat» blokida aytdik: kalitsiz SHA-256 zanjirini bazaga to'liq kirgan odam qaytadan yozib chiqa oladi. Himoya — oxirgi hash'ni vaqti-vaqti bilan boshqa tizimga chiqarib qo'yish.

3. Jurnalni "shovqin" bilan ko'mish. Hujumchi minglab so'rov yuborib, muhim yozuvni topib bo'lmaydigan qilmoqchi. Kanon interceptor'i faqat muvaffaqiyatli va @Audit belgilangan amalni yozadi — 403 lar jurnalga tushmaydi.

4. Jurnal orqali sir o'g'irlash. Jurnal uzoq saqlanadi va adminlar o'qiydi. tafsilot ga token yoki parol tushsa, u yillab ochiq turadi. Kanon faqat metod va yo'lni yozadi.

Asosiy himoya — huquqlar. session_replication_role ni faqat superuser (yoki unga alohida SET huquqi berilgan rol) o'zgartira oladi, DISABLE TRIGGER ni — jadval egasi yoki superuser. Ilova rollari esa faqat audit_yoz() ni chaqira oladi — buni «Bazada sinab ko'ramiz» bo'limida ko'rdik.

7. Ko'p uchraydigan xatolar

Belgi Sabab Davosi
Tekshiruv "hash mos emas" deydi, lekin hech kim tegmagan vaqt aniqligi yoki format yozish/tekshirishda farq kanonik satr bitta funksiyada, date_trunc
Zanjir ikkiga ayrildi parallel yozuvlar bir xil "oxirgi hash"ni o'qidi pg_advisory_xact_lock
Hisob o'chirilganda audit ham ketdi aktyor_id ga ON DELETE CASCADE FK FK'siz ustun
Audit 403 va 404 lar bilan to'lib ketdi hamma so'rov yoziladi faqat muvaffaqiyatli belgilangan amallar
Aktyor null bo'lib qoldi aktyor amaldan keyin olindi so'rov boshida olish
Testlar orasida audit tozalanmaydi jurnal alohida sxemada, TRUNCATE taqiqlangan testda "oldin/keyin" farqini solishtirish

Oxirgi qator — testdagi oldin o'zgaruvchisi shuning uchun: jurnalni tozalab bo'lmaydi, test faqat o'zi qo'shgan yozuvlarni tekshiradi.

8. Mashqlar

Mashqlar kurs/mashqlar/31/23-audit/ da. 2- va 3-mashq yechimlari Node 24.21.0 da tekshirilgan (biz ishga tushirdik).

1-mashq (oson): bo'shliqlarni to'ldiring

  • Faqat yozuv qo'shishga ruxsat beruvchi jadvalning inglizcha nomi —
  • Kanon SHA-256 hash'i necha bayt? —
  • Parallel yozuvlarni navbatga qo'yuvchi funksiya — pg__xact_lock
  • Ilova rollari jurnalga qaysi funksiya orqali yozadi? —
  • Trigger o'chirilib qilingan tahrir va o'chirishdan keyin audit_tekshir() nechta buzilish topdi? —
Yechim
  • append-only — UPDATE/DELETE/TRUNCATE trigger bilan taqiqlangan.
  • 32 — 256 bit; hex ko'rinishda 64 belgi.
  • advisory — tranzaksiya oxirigacha ushlanadigan maslahat qulfi.
  • audit_yoz — SECURITY DEFINER; jadvalga to'g'ridan-to'g'ri ruxsat yo'q.
  • 2 — o'zgartirilgan (hash) va o'chirilgandan keyingi (zanjir).

2-mashq (o'rta): obyekt id'sini topish

«Yaratilgan obyektning id'si yo'q» bo'limidagi kamchilikni tuzating. obyektId(params, javob) funksiyasini yozing: avval params.id, keyin params.kalit, ular bo'lmasa javob obyektidagi id (son yoki satr — doim satrga aylantiring), hech biri bo'lmasa null. javob undefined yoki obyekt bo'lmasa ham xato bermasin. Ishora: ?? (nullish birlashtirish), typeof.

Yechim
js
function obyektId(params, javob) {
  const p = params.id ?? params.kalit;
  if (typeof p === "string") return p;
  const id = typeof javob === "object" && javob !== null
    ? javob.id
    : undefined;
  if (typeof id === "number" || typeof id === "string") {
    return String(id);
  }
  return null;
}

console.log(obyektId({ id: "12" }, undefined));
console.log(obyektId({ kalit: "ai-qadamlar" }, { kalit: "x" }));
console.log(obyektId({}, { id: 7, kalit: "vz_abc" }));
console.log(obyektId({}, undefined));
console.log(obyektId({}, "matn"));

Konsolda:

text
12
ai-qadamlar
7
null
null

Uchinchi qator — API kalit yaratish holati: yo'lda parametr yo'q, lekin javobda yangi kalitning id'si bor. Interceptor'da mergeMap((javob) => …) ichida javob aynan shu qiymat — tuzatish bir qator. Ehtiyot bo'ling: javobda maxfiy qiymat (kalitning o'zi) bo'lishi mumkin — faqat id ni oling, butun javobni tafsilot ga yozmang.

3-mashq (qiyin): o'zgarish farqi

Audit tafsilot ida ko'pincha "nima o'zgardi" saqlanadi. farq(oldin, keyin, yashirin) funksiyasini yozing: ikki obyektning birinchi darajadagi kalitlari bo'yicha { kalit: [eski, yangi] } qaytarsin (faqat o'zgarganlari, qo'shilgan yoki olib tashlangan kalit ham — undefined o'rniga null). yashirin massividagi kalitlar qiymati "***" bilan almashtirilsin. Ishora: Set (Set), spread (spread), Object.keys.

Yechim
js
function farq(oldin, keyin, yashirin = []) {
  const kalitlar = new Set([...Object.keys(oldin),
    ...Object.keys(keyin)]);
  const natija = {};
  for (const k of kalitlar) {
    const a = oldin[k] ?? null;
    const b = keyin[k] ?? null;
    if (JSON.stringify(a) === JSON.stringify(b)) continue;
    natija[k] = yashirin.includes(k) ? ["***", "***"] : [a, b];
  }
  return natija;
}

const oldin = { yoqilgan: true, foiz: 30, rollar: [], kalit: "s1" };
const keyin = { yoqilgan: true, foiz: 50, rollar: ["admin"],
  kalit: "s2", tavsif: "AI" };
console.log(farq(oldin, keyin, ["kalit"]));
console.log(farq(oldin, oldin));

Konsolda:

text
{
  foiz: [ 30, 50 ],
  rollar: [ [], [ 'admin' ] ],
  kalit: [ '***', '***' ],
  tavsif: [ null, 'AI' ]
}
{}

Bayroq foizi 30 dan 50 ga oshdi, rol segmenti qo'shildi — bu keyingi darsdagi admin amali uchun tayyor tafsilot. Maxfiy kalit o'zgargani ma'lum, qiymati yo'q. Bir xil obyekt — bo'sh farq. Massivlar JSON.stringify bilan solishtirildi — chuqur obyektlar uchun bu soddalashtirish (kalitlar tartibi farq qilsa, "o'zgardi" deb chiqadi).

9. Real ishda

  • Qayerda uchraydi: bank va to'lov tizimlari, tibbiy yozuvlar, davlat xizmatlari, har SaaS'ning admin paneli. Kompaniyalar xavfsizlik sertifikatlarida (SOC 2, ISO 27001) audit jurnalini talab qiladi (hujjat bo'yicha, sinalmagan).
  • Intervyu savollari: "Audit log va oddiy log farqi?", "Audit jurnalini o'zgarmas qanday qilasiz?", "Admin trigger'ni o'chirsa, buni qanday bilasiz?"
  • Vositalar: Postgres darajasida pgaudit kengaytmasi (SQL buyruqlarini yozadi; kanonda sinalmagan); bulutda — boshqariladigan audit xizmatlari. Ilova darajasidagi audit ular o'rnini bosmaydi: "admin.bloklash" — biznes amali, SQL emas.
  • Kod review'da: "bu yangi admin yo'liga @Audit qo'yilganmi?", "tafsilot ga parol yoki token tushmaydimi?"

Xulosa

  • Audit — "kim, qachon, nimani": uzoq saqlanadi, o'zgartirilmaydi; oddiy log va tarix jadvalidan boshqa savolga javob beradi.
  • Birinchi qatlam — trigger: UPDATE/DELETE/TRUNCATE superuser uchun ham rad etiladi.
  • Ikkinchi qatlam — hash zanjiri: trigger'ni chetlab o'tgan tahrir va o'chirish audit_tekshir() da aniq id bilan topiladi — DISABLE TRIGGER bilan ham, jimroq session_replication_role = replica bilan ham.
  • Ilova faqat SECURITY DEFINER funksiya orqali yozadi; parallel yozuvlarni advisory qulf tartibga soladi.
  • Nest'da @Audit + global interceptor: faqat muvaffaqiyatli amal, aktyor so'rov boshida olinadi; amal va audit alohida tranzaksiyada — bu ochiq qaror.

Keyingi dars: Multi-tenancy — bitta ilovada ko'p tashkilotning ma'lumotini aralashtirmaslik modellarini o'lchaymiz.

Manbalar

Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
Audit log: kim, qachon, nimani — o'zgarmas jurnal va hash zanjiri — IlmHamroh