---
layout: 'page'
uri: '/framework/application/audit'
position: 5
slug: 'framework-application-audit'
parent: 'framework-application'
navTitle: 'Audit log'
title: 'Audit log'
description: 'Append-only zápis security-relevantních akcí, který přežije rollback business transakce.'
---

# Audit log


## K čemu ti to je

Když přijde stížnost _"někdo mi smazal admin účet"_, chceš najít kdo, kdy a odkud — bez toho, abys to musel rekonstruovat z application logu. Když ti backend hlásí 5 failed loginů za minutu, chceš vědět, na jaký nickname jely a z jaké IP. Když útočník zneužije refresh token, chceš mít čistý audit trail, ne 500 řádků slogu.

Audit log dělá jednu věc: zapíše každou security-relevantní akci jako jednu řádku do tabulky `audit_log`. Append-only — nikdo (včetně aplikace) řádky nemění.

Tři garance:

1. **Survive business rollback.** Když handler vrátí `AuthError` a celá business transakce se zruší, audit zápis tam zůstane. Login_failed musí persistovat — to je celý pointa.
2. **Per-request izolace.** Stejně jako [eventy](/framework/application/events), každý request má vlastní collector. Žádný leak mezi paralelními commandy.
3. **Failure-safe.** Pád audit zápisu se loguje, ale nikdy nezhasí business operaci. Degradovaný trail je lepší než 500.


## Krok za krokem

Scénář: po `CreateUser` zaznamenat, kdo a koho založil.

### 1. Zavolej `Record` v handleru po úspěšném zápisu

```go
func (h *CreateUserHandler) Handle(ctx context.Context, cmd CreateUserCommand) error {
    // ... business validation + save ...

    if err := h.users.Save(ctx, u); err != nil {
        return err
    }

    shared.AuditCollectorFromContext(ctx).Record(shared.AuditEvent{
        Action:     "user.created",
        TargetType: "user",
        TargetID:   u.ID,
        Metadata:   map[string]any{"role": u.Role},
    })

    return nil
}
```

`Action` je dotted string `domain.event` (např. `auth.login.failed`, `user.role_changed`). `Metadata` je libovolný JSON-serializovatelný map.

### 2. Co se stane dál

`AuditMiddleware` v command bus chainu:

1. Vytvoří collector a strčí ho do `ctx` před voláním handleru.
2. Handler runs, possibly does `Record(...)`.
3. Po handleru (success nebo error) middleware drainuje collector.
4. Pro každý event vytvoří `AuditRecord` (přidá `actor_user_id` z `ClaimsFromContext`, `actor_ip` z `ActorIPFromContext`, timestamp) a pošle ho do `AuditLogger.Save(...)`.
5. Save runs na raw connection pool — **mimo** business transakci, takže přežije rollback.

### 3. Co když je handler volaný mimo bus

`AuditCollectorFromContext` vrací **throwaway collector**, když ho nikdo do `ctx` neinjectoval (CLI, testy). Record je no-op — handler tedy nemusí nil-checkovat ani v testech, ani v `./bin/app create-user` bypassu.

### 4. Failure-safe contract

Pokud `AuditLogger.Save` selže (např. disk full):

- Chyba se zaloguje s `action`, `command`, `error`.
- Handler ji **nevidí** — business response zůstává úspěšná.

Důvod: audit trail je best-effort. Degradace logging > shoz produkce.


## Co se ti hodí vědět

**Architecture rule** — `AuditMiddleware` MUSÍ ležet outside `TransactionMiddleware` a `DispatchEventsMiddleware`. Pokud ji někdy přesouváš, drž tohle:

```
Recovery → Logging → Authorize → Audit → JobDispatcher → DispatchEvents → Transaction → handler
                              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
                              Audit zde, tx vně, takže rollback audit nesmaže.
```

**`r.DB.DB()` výjimka** — `infrastructure/sqlite/audit/repository.go` používá raw connection pool, ne `r.Conn(ctx)`. To je porušení obecného pravidla, ale **úmyslné**: audit nesmí joinovat caller's tx. Stejně tak `user.Repository.RecordFailedLogin` / `ResetFailedLogin` (brute-force counter). Jiné repos to nedělají.

**Detached ctx pro flush** — middleware používá `context.WithoutCancel(ctx)` aby disconnected klient nemohl zabít audit zápis prostředky cancel signal.

**Co loguješ ty, co middleware** — `Action`, `TargetType`, `TargetID`, `Metadata` jsou tvé. `actor_user_id`, `actor_ip`, `created_at`, `id` (UUID) doplní middleware. Nikdy je nestavej do `Metadata` ručně.

**Konvence pro `Action`** — dotted lowercase `<domain>.<event>` (`auth.login.succeeded`, `user.password_changed`). Pomáhá to grep + budoucímu observability nástroji.


## Co lze nastavit

Žádné env vary. Lock policy je v `application/auth/command/login.go` jako konstanty (`loginLockThreshold`, `loginLockWindow`, `loginLockDuration`). Audit nemá žádný runtime config — buď je nasazený a píše, nebo není.

| Konstanta | Hodnota | Význam |
|---|---|---|
| `loginLockThreshold` | `5` | Failed loginů uvnitř `loginLockWindow` → lock. |
| `loginLockWindow` | `10m` | Reset counter, pokud poslední fail byl dál v minulosti. |
| `loginLockDuration` | `15m` | Délka locku po dosažení threshold. |


## Eventy, které aplikace dnes loguje

| Action | Kde se vyhlašuje | Metadata |
|---|---|---|
| `auth.login.succeeded` | `LoginHandler` po vydání tokenu | — |
| `auth.login.failed` | `LoginHandler` po failed Verify | `{nickname}` |
| `auth.login.blocked_while_locked` | `LoginHandler` když attempt na locked účet | — |
| `auth.account.locked` | `LoginHandler` po dosažení threshold | `{locked_until}` |
| `auth.token.theft_detected` | `RefreshTokenHandler` (used_at != nil OR concurrent race) | `{reason}` |
| `user.created` | `CreateUserHandler` | `{role}` |
| `user.role_changed` | `UpdateUserHandler` jen pokud role změnila | `{new_role}` |
| `user.deleted` | `DeleteUserHandler` | — |
| `user.password_changed` | `ChangePasswordHandler` | — |

---

[← Events](/framework/application/events.md) | [Presentation →](/framework/presentation.md)