'array', 'created_at' => 'datetime', ]; } public function scopeVisibleToTeam(Builder $query, int $teamId, bool $includeInstanceEvents = false): Builder { return $query->where(function (Builder $query) use ($includeInstanceEvents, $teamId): void { $query->where('team_id', $teamId) ->when($includeInstanceEvents, fn (Builder $query) => $query->orWhereNull('team_id')); }); } public function scopeFiltered( Builder $query, string $search = '', string $action = 'all', string $source = 'all', bool $searchSensitiveFields = true, ): Builder { return $query ->when($action !== 'all', fn (Builder $query) => $query->where('action', $action)) ->when($source !== 'all', fn (Builder $query) => $query->where('source', $source)) ->when($search !== '', function (Builder $query) use ($search, $searchSensitiveFields): void { $query->where(function (Builder $query) use ($search, $searchSensitiveFields): void { $query->where('event', 'like', "%{$search}%") ->orWhere('description', 'like', "%{$search}%") ->orWhere('resource_name', 'like', "%{$search}%") ->orWhere('actor_name', 'like', "%{$search}%") ->when($searchSensitiveFields, fn (Builder $query) => $query->orWhere('actor_email', 'like', "%{$search}%")); }); }); } public function scopeLatestFirst(Builder $query): Builder { return $query->latest('created_at')->latest('id'); } /** * @param array $context */ public static function record(string $event, array $context = []): void { try { $attributes = self::attributesFor($event, $context); DB::afterCommit(function () use ($attributes): void { defer(function () use ($attributes): void { try { self::query()->create($attributes); } catch (Throwable $exception) { Log::warning('Audit event persistence failed', [ 'event' => $attributes['event'], 'exception' => $exception::class, ]); } })->always(); }); } catch (Throwable $exception) { Log::warning('Audit event preparation failed', [ 'event' => $event, 'exception' => $exception::class, ]); } } /** * @param array $context * @return array */ private static function attributesFor(string $event, array $context): array { $teamId = data_get(auth()->user()?->currentAccessToken(), 'team_id') ?? data_get($context, 'team_id') ?? currentTeam()?->id ?? self::teamIdFromContext($context); $parts = explode('.', $event); $source = $parts[0] ?? 'system'; $resourceType = data_get($context, 'resource') ?? ($parts[1] ?? null); $action = data_get($context, 'action') ?? (end($parts) ?: 'event'); $resourceUuid = self::firstContextValue($context, $resourceType ? "{$resourceType}_uuid" : null, '_uuid'); $resourceName = self::firstContextValue($context, $resourceType ? "{$resourceType}_name" : null, '_name'); $user = auth()->user(); $token = $user?->currentAccessToken(); $actorType = match (true) { in_array($source, ['mcp', 'webhook', 'system', 'scheduler'], true) => $source, $token !== null => 'api_token', $user !== null => 'user', default => 'system', }; return [ 'team_id' => $teamId, 'event' => $event, 'source' => $source, 'action' => $action, 'actor_type' => $actorType, 'actor_id' => $user?->id, 'actor_name' => $user?->name, 'actor_email' => $user?->email, 'actor_token_id' => $token?->id, 'actor_token_name' => $token?->name, 'resource_type' => $resourceType, 'resource_uuid' => $resourceUuid, 'resource_name' => $resourceName, 'description' => data_get($context, 'audit_description') ?? trim(($resourceName ?? Str::headline((string) $resourceType)).' '.Str::headline($action)), 'metadata' => self::redact($context), 'ip_address' => app()->bound('request') ? request()->ip() : null, 'user_agent' => app()->bound('request') ? Str::limit((string) request()->userAgent(), 200, '') : null, ]; } /** * @param array $context */ private static function teamIdFromContext(array $context): ?int { $applicationUuid = data_get($context, 'application_uuid'); if (! is_string($applicationUuid) || $applicationUuid === '') { return null; } return Application::query() ->where('uuid', $applicationUuid) ->first()?->team()?->id; } public static function pruneExpired(): int { return self::query() ->where('created_at', '<', now()->subDays(90)) ->delete(); } /** * @param array $context */ private static function firstContextValue(array $context, ?string $preferredKey, string $suffix): mixed { if ($preferredKey !== null && filled(data_get($context, $preferredKey))) { return data_get($context, $preferredKey); } $key = Arr::first(array_keys($context), fn (string $key): bool => str_ends_with($key, $suffix)); return $key ? data_get($context, $key) : null; } private static function redact(mixed $value, ?string $key = null): mixed { if ($key !== null && preg_match('/password|secret|token|private_key|signature|credential|invitation_email|api_key|access_key|authorization|cookie/i', $key)) { return '[REDACTED]'; } if (! is_array($value)) { return $value; } return collect($value) ->mapWithKeys(fn (mixed $item, string|int $itemKey): array => [ $itemKey => self::redact($item, (string) $itemKey), ]) ->all(); } }