Три группы накопившихся 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 плана постановки в боевой). Содержание ветки не трогает.
19 KiB
P1 — Полное покрытие auth_log + автор/IP в activity_log
Status: ✅ DONE — 22.05.2026. Subagent-driven execution на ветке
worktree-audit-p1-auth(от P0-baselinea575d55). 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 closureuse()lists (transition/destroy/restore); (4) Task 8 integration test задокументировал Sanctum/SessionGuard interaction в shared test app (Auth::shouldUse('sanctum')от auth:sanctum middleware ломает следующиеAuth::login()в той же тестовой функции — fixapp('auth')->forgetGuards(); setDefaultDriver('web')передAuth::login). NB:--parallelfull-suite не запущен (shared DB + concurrent worktrees) — заменён targeted sequential regression.
For agentic workers: REQUIRED SUB-SKILL: Use
superpowers:subagent-driven-development(recommended) orsuperpowers: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:
- Существующая приватная
logAuthEvent()вAuthController(:416-435) выносится в трейтApp\Http\Controllers\Concerns\WritesAuthLog. Подключается вAuthController,TwoFactorController,TwoFactorSetupController,PasswordResetController— единая точка записи (решение E=a). - Все
ActivityLog::createвDealController(4 точки) иDealBulkActionController(3 точки) получаютuser_idиз$request->user()->id, плюсip_addressиuser_agent. Прошлое не бэкфилим (решение B=a). - 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.phpapp/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— 4ActivityLog::create.app/app/Http/Controllers/Api/DealBulkActionController.php:99/170/234— 3ActivityLog::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.