# P1 — Полное покрытие `auth_log` + автор/IP в `activity_log`

> **Status: ✅ DONE — 22.05.2026.** Subagent-driven execution на ветке `worktree-audit-p1-auth` (от P0-baseline `a575d55`). 10 commits (Tasks 1-8 + gate). Auth-suite **131/131 passing (537 assertions)**; touched-area regression GREEN: AuthControllerTest 13/13, TwoFactor 16/16, TwoFactorSetup 10/10, ForgotPassword|ResetPassword 12/12, DealCreate/Update/Transition 26/26, DealBulkAction 14/14. Pint **clean** на изменённых файлах; Larastan **production code clean** (один `nullsafe.neverNull` в `AuthController.php:96` исправлен — `$user` уже non-null в `if (! $user->is_active)`; line 84 `?->` оставлен корректно — `$user` там может быть null). Plan-level deviations: (1) Task 5 implementer добавил 4 теста вместо 3 (bonus password_reset_failed на невалидном токене); (2) Task 6 — `request()` helper вместо `$request` (closures без `$request` в `use()`); (3) Task 7 — `$request` добавлен в 3 closure `use()` lists (transition/destroy/restore); (4) Task 8 integration test задокументировал Sanctum/SessionGuard interaction в shared test app (`Auth::shouldUse('sanctum')` от auth:sanctum middleware ломает следующие `Auth::login()` в той же тестовой функции — fix `app('auth')->forgetGuards(); setDefaultDriver('web')` перед `Auth::login`). NB: `--parallel` full-suite не запущен (shared DB + concurrent worktrees) — заменён targeted sequential regression.

> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans`. Steps use checkbox (`- [ ]`).

**Goal:** Закрыть журнал входа `auth_log` на все остальные auth-события (выход, 2FA setup/verify/recovery, password reset, регистрация) и заполнять `user_id`/`ip_address`/`user_agent` во **всех** `ActivityLog::create` (сейчас все 8 точек проставляют NULL).

**Architecture:**

1. Существующая приватная `logAuthEvent()` в `AuthController` ([:416-435](../../../app/app/Http/Controllers/Api/AuthController.php#L416)) выносится в трейт `App\Http\Controllers\Concerns\WritesAuthLog`. Подключается в `AuthController`, `TwoFactorController`, `TwoFactorSetupController`, `PasswordResetController` — единая точка записи (решение E=a).
2. Все `ActivityLog::create` в `DealController` (4 точки) и `DealBulkActionController` (3 точки) получают `user_id` из `$request->user()->id`, плюс `ip_address` и `user_agent`. Прошлое не бэкфилим (решение B=a).
3. Hash-chain trigger на `auth_log` уже стоит ([db/schema.sql:3032](../../../db/schema.sql#L3032)) — новые записи защищены автоматически.

**Tech Stack:** PHP 8.3, Laravel 13, Pest 4, PostgreSQL 16.

---

## File Structure

**New:**

- `app/app/Http/Controllers/Concerns/WritesAuthLog.php` — трейт.
- `app/tests/Unit/Concerns/WritesAuthLogTest.php`
- `app/tests/Feature/Auth/AuthLogCoverageTest.php` — все auth-события.
- `app/tests/Feature/Deals/ActivityLogAttributionTest.php` — автор/IP в `activity_log`.

**Modified:**

- `app/app/Http/Controllers/Api/AuthController.php` — `logout`, `registerVerify`; убрать локальную `logAuthEvent`, использовать трейт.
- `app/app/Http/Controllers/Api/TwoFactorController.php` — `verifyTwoFactor` (успех+неудача), `useRecoveryCode` (успех+неудача).
- `app/app/Http/Controllers/Api/TwoFactorSetupController.php` — `init`, `confirm`, `disable`, `regenerateRecoveryCodes`.
- `app/app/Http/Controllers/Api/PasswordResetController.php` — `forgotPassword`, `resetPassword`.
- `app/app/Http/Controllers/Api/DealController.php:387/400/412/523` — 4 `ActivityLog::create`.
- `app/app/Http/Controllers/Api/DealBulkActionController.php:99/170/234` — 3 `ActivityLog::insert`-блока.

---

## Task 1 — `WritesAuthLog` трейт

**Files:**

- Create: `app/app/Http/Controllers/Concerns/WritesAuthLog.php`
- Test: `app/tests/Unit/Concerns/WritesAuthLogTest.php`

- [ ] **Step 1: failing test**

```php
<?php declare(strict_types=1);

use Illuminate\Support\Facades\DB;

class DummyAuth { use \App\Http\Controllers\Concerns\WritesAuthLog;
    public function fire(?int $userId, ?int $tenantId): void {
        $this->logAuthEvent('login_success', $userId, $tenantId, 'a@b.c', '1.2.3.4', 'UA', null);
    }
}

it('writes auth_log row with all fields', function () {
    (new DummyAuth)->fire(7, 1);
    $row = DB::table('auth_log')->latest('id')->first();
    expect($row->event)->toBe('login_success')
        ->and($row->actor_type)->toBe('tenant_user')
        ->and((int) $row->user_id)->toBe(7)
        ->and((int) $row->tenant_id)->toBe(1)
        ->and((string) $row->ip_address)->toBe('1.2.3.4')
        ->and($row->user_agent)->toBe('UA');
});

it('actor_type=tenant_user even if user NULL (anti-enumeration)', function () {
    (new DummyAuth)->fire(null, null);
    $row = DB::table('auth_log')->latest('id')->first();
    expect($row->actor_type)->toBe('tenant_user')->and($row->user_id)->toBeNull();
});
```

- [ ] **Step 2: confirm RED**
- [ ] **Step 3: implement**

```php
<?php declare(strict_types=1);

namespace App\Http\Controllers\Concerns;

use Illuminate\Support\Facades\DB;

/**
 * Запись в auth_log (защищён hash-chain тригером, см. db/schema.sql:3032).
 * Используется в AuthController, TwoFactorController, TwoFactorSetupController,
 * PasswordResetController — единственная точка записи auth-событий.
 *
 * Канонические event-strings (расширяемо):
 *   login_success, login_failed, logout, register_success,
 *   2fa_verify_success, 2fa_verify_failed, 2fa_recovery_used,
 *   2fa_setup_init, 2fa_setup_confirmed, 2fa_disabled, 2fa_recovery_regenerated,
 *   password_reset_requested, password_reset_completed
 */
trait WritesAuthLog
{
    protected function logAuthEvent(
        string $event,
        ?int $userId,
        ?int $tenantId,
        ?string $email,
        ?string $ip,
        ?string $userAgent,
        ?string $failureReason,
    ): void {
        DB::table('auth_log')->insert([
            'actor_type' => 'tenant_user',
            'tenant_id' => $tenantId,
            'user_id' => $userId,
            'email' => $email,
            'event' => $event,
            'ip_address' => $ip,
            'user_agent' => $userAgent,
            'failure_reason' => $failureReason,
            'created_at' => now(),
        ]);
    }
}
```

- [ ] **Step 4: confirm GREEN**
- [ ] **Step 5: commit**

```bash
git add app/app/Http/Controllers/Concerns/WritesAuthLog.php app/tests/Unit/Concerns/WritesAuthLogTest.php
git commit -m "feat(auth): WritesAuthLog trait — shared auth_log writer"
```

---

## Task 2 — AuthController → use trait, log `logout` + `register_success`

**Files:**

- Modify: `app/app/Http/Controllers/Api/AuthController.php`
- Test: `app/tests/Feature/Auth/AuthLogCoverageTest.php` (NEW, накапливается)

- [ ] **Step 1: failing test (два кейса)**

```php
it('logs logout event', function () {
    $u = User::factory()->create();
    $this->actingAs($u)->postJson('/api/auth/logout')->assertOk();
    expect(DB::table('auth_log')->where('event', 'logout')->where('user_id', $u->id)->count())->toBe(1);
});

it('logs register_success on registerVerify', function () {
    // моделируем session pending → POST register/verify → ожидаем event=register_success, user_id=<new>
});
```

- [ ] **Step 2: confirm RED**
- [ ] **Step 3: implement — `use WritesAuthLog`, удалить локальный приватный `logAuthEvent`, добавить вызовы**

```php
class AuthController extends Controller
{
    use \App\Http\Controllers\Concerns\WritesAuthLog;

    public function logout(Request $request): JsonResponse
    {
        $userId = $request->user()?->id;
        $tenantId = $request->user()?->tenant_id;

        Auth::guard('web')->logout();
        $request->session()->invalidate();
        $request->session()->regenerateToken();

        $this->logAuthEvent('logout', $userId, $tenantId, null, $request->ip(), $request->userAgent(), null);

        return response()->json(['message' => 'Вы вышли из системы.']);
    }

    public function registerVerify(RegisterVerifyRequest $request): JsonResponse
    {
        // ... existing logic ...
        Auth::login($user);
        $request->session()->regenerate();

        $this->logAuthEvent('register_success', $user->id, $user->tenant_id, $user->email,
            $request->ip(), $request->userAgent(), null);

        return response()->json([...], 201);
    }
}
```

- [ ] **Step 4: confirm GREEN**
- [ ] **Step 5: commit**

---

## Task 3 — TwoFactorController → log verify (success+fail) + recovery (success+fail)

**Files:**

- Modify: `app/app/Http/Controllers/Api/TwoFactorController.php:41,110`

- [ ] **Step 1: failing test (4 кейса)** — `2fa_verify_success`, `2fa_verify_failed`, `2fa_recovery_used`, `2fa_recovery_failed` (с правильным `failure_reason`).
- [ ] **Step 2: RED**
- [ ] **Step 3: implement — `use WritesAuthLog`; вставить вызовы на каждой ветке (включая обе неудачи)**

```php
// после Auth::login($user, $remember) в verifyTwoFactor():
$this->logAuthEvent('2fa_verify_success', $user->id, $user->tenant_id, $user->email,
    $request->ip(), $request->userAgent(), null);

// в ветке неверного кода (RateLimiter::hit ...):
$this->logAuthEvent('2fa_verify_failed', $user->id, $user->tenant_id, $user->email,
    $request->ip(), $request->userAgent(), 'invalid_code');

// в useRecoveryCode() success ветке:
$this->logAuthEvent('2fa_recovery_used', $user->id, $user->tenant_id, $user->email,
    $request->ip(), $request->userAgent(), null);

// неверный recovery:
$this->logAuthEvent('2fa_recovery_failed', $user->id, $user->tenant_id, $user->email,
    $request->ip(), $request->userAgent(), 'invalid_or_used');
```

- [ ] **Step 4: GREEN**
- [ ] **Step 5: commit**

---

## Task 4 — TwoFactorSetupController → log init/confirm/disable/regen

**Files:**

- Modify: `app/app/Http/Controllers/Api/TwoFactorSetupController.php:39,80,133,163`

- [ ] **Step 1: failing test (4 кейса)** — `2fa_setup_init`, `2fa_setup_confirmed`, `2fa_disabled`, `2fa_recovery_regenerated`. Для disable — отдельно неудачный пароль = `2fa_disable_failed` (failure_reason='invalid_password').
- [ ] **Step 2: RED**
- [ ] **Step 3: implement — `use WritesAuthLog`; вызовы на success-ветках всех 4 методов + 1 failed-ветка**

```php
// в init() после $request->session()->put(...):
$this->logAuthEvent('2fa_setup_init', $user->id, $user->tenant_id, $user->email,
    $request->ip(), $request->userAgent(), null);

// в confirm() после $request->session()->forget(...):
$this->logAuthEvent('2fa_setup_confirmed', $user->id, $user->tenant_id, $user->email,
    $request->ip(), $request->userAgent(), null);

// в disable() после DB::transaction(... totp_enabled=false ...):
$this->logAuthEvent('2fa_disabled', $user->id, $user->tenant_id, $user->email,
    $request->ip(), $request->userAgent(), null);

// в regenerateRecoveryCodes() после DB::transaction:
$this->logAuthEvent('2fa_recovery_regenerated', $user->id, $user->tenant_id, $user->email,
    $request->ip(), $request->userAgent(), null);
```

- [ ] **Step 4: GREEN**
- [ ] **Step 5: commit**

---

## Task 5 — PasswordResetController → log forgot/reset (success+fail)

**Files:**

- Modify: `app/app/Http/Controllers/Api/PasswordResetController.php:57,94`

- [ ] **Step 1: failing test (3 кейса)** — `password_reset_requested` (всегда пишется, даже если email неизвестен — anti-enumeration на UI остаётся, но в журнале фиксируется), `password_reset_completed` (на success Password::reset), `password_reset_failed` (на статусе != PASSWORD_RESET).
- [ ] **Step 2: RED**
- [ ] **Step 3: implement**

```php
class PasswordResetController extends Controller
{
    use \App\Http\Controllers\Concerns\WritesAuthLog;

    public function forgotPassword(...): JsonResponse
    {
        // ... existing ...
        $userId = \App\Models\User::where('email', $email)->value('id');
        $this->logAuthEvent('password_reset_requested', $userId, null, $email,
            $request->ip(), $request->userAgent(), $userId === null ? 'unknown_email' : null);

        return response()->json([...]);
    }

    public function resetPassword(...): JsonResponse
    {
        // ... existing ...
        if ($status !== Password::PASSWORD_RESET) {
            $this->logAuthEvent('password_reset_failed', null, null, $email,
                $request->ip(), $request->userAgent(), (string) $status);
            return response()->json([...], 422);
        }
        $userId = \App\Models\User::where('email', $email)->value('id');
        $this->logAuthEvent('password_reset_completed', $userId, null, $email,
            $request->ip(), $request->userAgent(), null);
        return response()->json([...]);
    }
}
```

- [ ] **Step 4: GREEN**
- [ ] **Step 5: commit**

---

## Task 6 — DealController: автор/IP в 4 ActivityLog::create

**Files:**

- Modify: `app/app/Http/Controllers/Api/DealController.php:387,400,412,523`
- Test: `app/tests/Feature/Deals/ActivityLogAttributionTest.php` (NEW)

- [ ] **Step 1: failing test (4 кейса)**

```php
it('manual store sets user_id and ip in activity_log', function () {
    $u = User::factory()->create();
    $this->actingAs($u)->withServerVariables(['REMOTE_ADDR' => '10.1.2.3'])
        ->postJson('/api/deals', ['project_name' => 'X', 'phone' => '79991234567'])->assertCreated();
    $row = DB::table('activity_log')->where('event', 'deal.created')->latest('id')->first();
    expect((int) $row->user_id)->toBe($u->id)
        ->and((string) $row->ip_address)->toBe('10.1.2.3');
});

it('comment update sets user_id', function () { /* PATCH /api/deals/{id} с comment */ });
it('status update sets user_id', function () { /* PATCH /api/deals/{id} с status */ });
it('manager update sets user_id', function () { /* PATCH /api/deals/{id} с manager_id */ });
```

- [ ] **Step 2: RED**
- [ ] **Step 3: implement — заменить все 4 `'user_id' => null` на актуальные значения**

```php
// DealController.php — все 4 ActivityLog::create:
ActivityLog::create([
    'tenant_id' => $tenantId,
    'user_id' => (int) $request->user()->id, // было: null
    'deal_id' => $deal->id,
    'event' => ActivityLog::EVENT_DEAL_*, // (existing)
    'context' => [...],
    'ip_address' => $request->ip(),
    'user_agent' => $request->userAgent(),
]);
```

Заметка: schema `activity_log` уже имеет колонки `ip_address` и `user_agent` ([db/schema.sql:1775-1776](../../../db/schema.sql#L1775)) — заполнение не требует миграции.

- [ ] **Step 4: GREEN**
- [ ] **Step 5: commit**

```bash
git commit -m "feat(audit): activity_log captures actor user_id + ip + UA in DealController"
```

---

## Task 7 — DealBulkActionController: автор/IP в 3 ActivityLog::insert

**Files:**

- Modify: `app/app/Http/Controllers/Api/DealBulkActionController.php:99-112,170-179,234-243`

- [ ] **Step 1: failing test (3 кейса: bulk transition, bulk destroy, bulk restore)** — для каждой записи в logRows ожидаем `user_id = $request->user()->id, ip_address = '...'`.
- [ ] **Step 2: RED**
- [ ] **Step 3: implement — в каждой из трёх $logRows map-конструкций добавить актуальные поля**

```php
$logRows = $changed->map(fn (Deal $d) => [
    'tenant_id' => $tenantId,
    'user_id' => (int) $request->user()->id, // было: null
    'deal_id' => $d->id,
    'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
    'context' => json_encode([...]),
    'ip_address' => $request->ip(),
    'user_agent' => $request->userAgent(),
    'created_at' => $now,
])->all();
```

То же для `destroy()` и `restore()`.

- [ ] **Step 4: GREEN**
- [ ] **Step 5: commit**

```bash
git commit -m "feat(audit): activity_log captures actor in bulk deal actions"
```

---

## Task 8 — Integration: full auth-flow coverage

**Files:**

- Test: `app/tests/Feature/Auth/AuthLogCoverageTest.php` — финальный E2E прогон

- [ ] **Step 1: test — единый сценарий «полный auth-flow одного user'а»**

```php
it('full auth flow writes all expected events', function () {
    // 1. POST /api/auth/register/start → start (не пишется — pending)
    // 2. POST /api/auth/register/verify → event=register_success
    // 3. POST /api/auth/2fa/init → event=2fa_setup_init
    // 4. POST /api/auth/2fa/confirm → event=2fa_setup_confirmed
    // 5. POST /api/auth/logout → event=logout
    // 6. POST /api/auth/login → event=login_success
    // 7. POST /api/auth/2fa/verify → event=2fa_verify_success
    // 8. POST /api/auth/2fa/disable → event=2fa_disabled
    // 9. POST /api/auth/forgot → event=password_reset_requested
    // 10. POST /api/auth/reset-password → event=password_reset_completed
    expect(DB::table('auth_log')->pluck('event')->all())
        ->toContain('register_success', '2fa_setup_init', '2fa_setup_confirmed',
            'logout', 'login_success', '2fa_verify_success', '2fa_disabled',
            'password_reset_requested', 'password_reset_completed');
});
```

- [ ] **Step 2: RED → GREEN**
- [ ] **Step 3: commit**

---

## Task 9 — Full regression (verification gate)

- [ ] **Step 1: запустить полный прогон**

```bash
cd app && php artisan test --parallel
cd app && composer pint && composer stan
```

- [ ] **Step 2: пометить план DONE**

---

## Self-Review

- **Spec coverage:**
  - logout — Task 2 ✓
  - registration — Task 2 (register_success) ✓
  - 2FA verify success + fail — Task 3 ✓
  - 2FA recovery success + fail — Task 3 ✓
  - 2FA setup init/confirm/disable/regen — Task 4 ✓
  - Password reset request + complete + fail — Task 5 ✓
  - DealController автор/IP (4 точки) — Task 6 ✓
  - DealBulkActionController автор/IP (3 точки) — Task 7 ✓
  - Полный E2E — Task 8 ✓
- **Placeholder scan:** все шаги содержат реальный код и точные пути; задачи 3 и 4 показывают код для каждой ветки.
- **Type consistency:** `logAuthEvent(string, ?int, ?int, ?string, ?string, ?string, ?string)` — одинаковая сигнатура трейта используется во всех 4 контроллерах.
- **Out-of-scope:** ПДн / impersonation — Plan A; project mutations / API-keys / webhook URL — Plan C.

---

## Execution

После сохранения — `superpowers:subagent-driven-development` или `superpowers:executing-plans`.
