Files
portal/docs/superpowers/plans/2026-05-22-audit-auth-attribution.md
T
Дмитрий 8e8f06ebe7 chore: working tree cleanup pre-llm-first-router merge
Три группы накопившихся auto-правок (НЕ ручные):

1. markdownlint --fix auto-format (~25 .md в docs/superpowers/, docs/security/marketing-vet.md, docs/adr/015, docs/deploy/lkomega-runbook): MD031/MD032 (blank lines around fence/list) + MD004 (bullet markers `+`→`-`). Содержательных текстовых правок 3: ADR-015 bullet, sprint5d-cleanup bullet, router-discipline trailing space.

2. lefthook 2.1.6 → 2.1.8 (package.json + lock): patch-bump, авто-резолвил npm.

3. Observer runtime (docs/observer/): episodes-2026-05.jsonl +420 строк (текущая активность мозга), STATUS.md regen, .pii-counters / .read-counter тики, +2026-05-24-brain-retro.md note.

Цель — разблокировать merge feat/llm-first-router → main (этап 0 плана постановки в боевой). Содержание ветки не трогает.
2026-05-25 14:23:11 +03:00

19 KiB
Raw Blame History

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\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) — новые записи защищены автоматически.

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.phplogout, registerVerify; убрать локальную logAuthEvent, использовать трейт.
  • app/app/Http/Controllers/Api/TwoFactorController.phpverifyTwoFactor (успех+неудача), useRecoveryCode (успех+неудача).
  • app/app/Http/Controllers/Api/TwoFactorSetupController.phpinit, confirm, disable, regenerateRecoveryCodes.
  • app/app/Http/Controllers/Api/PasswordResetController.phpforgotPassword, 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 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 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
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 (два кейса)

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, добавить вызовы
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; вставить вызовы на каждой ветке (включая обе неудачи)

// после 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-ветка

// в 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

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 кейса)

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 на актуальные значения
// 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) — заполнение не требует миграции.

  • Step 4: GREEN
  • Step 5: commit
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-конструкций добавить актуальные поля

$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
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'а»

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: запустить полный прогон
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.