php-mcp/src/Mcp/Models/McpAuditLog.php

384 lines
11 KiB
PHP
Raw Normal View History

2026-01-26 20:57:41 +00:00
<?php
declare(strict_types=1);
namespace Core\Mcp\Models;
2026-01-26 20:57:41 +00:00
use Core\Mod\Tenant\Concerns\BelongsToWorkspace;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* MCP Audit Log - immutable audit trail for MCP tool executions.
*
* Implements a hash chain for tamper detection. Each entry contains
* a hash of the previous entry, creating a verifiable chain of custody.
*
* @property int $id
* @property string $server_id
* @property string $tool_name
* @property int|null $workspace_id
* @property string|null $session_id
* @property array|null $input_params
* @property array|null $output_summary
* @property bool $success
* @property int|null $duration_ms
* @property string|null $error_code
* @property string|null $error_message
* @property string|null $actor_type
* @property int|null $actor_id
* @property string|null $actor_ip
* @property bool $is_sensitive
* @property string|null $sensitivity_reason
* @property string|null $previous_hash
* @property string $entry_hash
* @property string|null $agent_type
* @property string|null $plan_slug
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon|null $updated_at
*/
class McpAuditLog extends Model
{
use BelongsToWorkspace;
/**
* Actor types.
*/
public const ACTOR_USER = 'user';
public const ACTOR_API_KEY = 'api_key';
public const ACTOR_SYSTEM = 'system';
/**
* The table associated with the model.
*/
protected $table = 'mcp_audit_logs';
/**
* Indicates if the model should be timestamped.
* We handle timestamps manually for immutability.
*/
public $timestamps = true;
/**
* The attributes that are mass assignable.
*/
protected $fillable = [
'server_id',
'tool_name',
'workspace_id',
'session_id',
'input_params',
'output_summary',
'success',
'duration_ms',
'error_code',
'error_message',
'actor_type',
'actor_id',
'actor_ip',
'is_sensitive',
'sensitivity_reason',
'previous_hash',
'entry_hash',
'agent_type',
'plan_slug',
];
/**
* The attributes that should be cast.
*/
protected $casts = [
'input_params' => 'array',
'output_summary' => 'array',
'success' => 'boolean',
'duration_ms' => 'integer',
'actor_id' => 'integer',
'is_sensitive' => 'boolean',
'created_at' => 'datetime',
];
/**
* Boot the model.
*/
protected static function boot(): void
{
parent::boot();
// Prevent updates to maintain immutability
static::updating(function (self $model) {
// Allow only specific fields to be updated (for soft operations)
$allowedChanges = ['updated_at'];
$changes = array_keys($model->getDirty());
foreach ($changes as $change) {
if (! in_array($change, $allowedChanges)) {
throw new \RuntimeException(
'Audit log entries are immutable. Cannot modify: '.$change
);
}
}
});
// Prevent deletion
static::deleting(function () {
throw new \RuntimeException(
'Audit log entries cannot be deleted. They are immutable for compliance purposes.'
);
});
}
// -------------------------------------------------------------------------
// Relationships
// -------------------------------------------------------------------------
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
// -------------------------------------------------------------------------
// Scopes
// -------------------------------------------------------------------------
/**
* Filter by server.
*/
public function scopeForServer(Builder $query, string $serverId): Builder
{
return $query->where('server_id', $serverId);
}
/**
* Filter by tool name.
*/
public function scopeForTool(Builder $query, string $toolName): Builder
{
return $query->where('tool_name', $toolName);
}
/**
* Filter by session.
*/
public function scopeForSession(Builder $query, string $sessionId): Builder
{
return $query->where('session_id', $sessionId);
}
/**
* Filter successful calls.
*/
public function scopeSuccessful(Builder $query): Builder
{
return $query->where('success', true);
}
/**
* Filter failed calls.
*/
public function scopeFailed(Builder $query): Builder
{
return $query->where('success', false);
}
/**
* Filter sensitive tool calls.
*/
public function scopeSensitive(Builder $query): Builder
{
return $query->where('is_sensitive', true);
}
/**
* Filter by actor type.
*/
public function scopeByActorType(Builder $query, string $actorType): Builder
{
return $query->where('actor_type', $actorType);
}
/**
* Filter by actor.
*/
public function scopeByActor(Builder $query, string $actorType, int $actorId): Builder
{
return $query->where('actor_type', $actorType)
->where('actor_id', $actorId);
}
/**
* Filter by date range.
*/
public function scopeInDateRange(Builder $query, string|\DateTimeInterface $start, string|\DateTimeInterface $end): Builder
{
return $query->whereBetween('created_at', [$start, $end]);
}
/**
* Filter for today.
*/
public function scopeToday(Builder $query): Builder
{
return $query->whereDate('created_at', today());
}
/**
* Filter for last N days.
*/
public function scopeLastDays(Builder $query, int $days): Builder
{
return $query->where('created_at', '>=', now()->subDays($days));
}
// -------------------------------------------------------------------------
// Hash Chain Methods
// -------------------------------------------------------------------------
/**
* Compute the hash for this entry.
* Uses SHA-256 to create a deterministic hash of the entry data.
*/
public function computeHash(): string
{
$data = [
'id' => $this->id,
'server_id' => $this->server_id,
'tool_name' => $this->tool_name,
'workspace_id' => $this->workspace_id,
'session_id' => $this->session_id,
'input_params' => $this->input_params,
'output_summary' => $this->output_summary,
'success' => $this->success,
'duration_ms' => $this->duration_ms,
'error_code' => $this->error_code,
'actor_type' => $this->actor_type,
'actor_id' => $this->actor_id,
'actor_ip' => $this->actor_ip,
'is_sensitive' => $this->is_sensitive,
'previous_hash' => $this->previous_hash,
'created_at' => $this->created_at?->toIso8601String(),
];
return hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
}
/**
* Verify this entry's hash is valid.
*/
public function verifyHash(): bool
{
return $this->entry_hash === $this->computeHash();
}
/**
* Verify the chain link to the previous entry.
*/
public function verifyChainLink(): bool
{
if ($this->previous_hash === null) {
// First entry in chain - check there's no earlier entry
return ! static::where('id', '<', $this->id)->exists();
}
$previous = static::where('id', '<', $this->id)
->orderByDesc('id')
->first();
if (! $previous) {
return false; // Previous entry missing
}
return $this->previous_hash === $previous->entry_hash;
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
/**
* Get duration formatted for humans.
*/
public function getDurationForHumans(): string
{
if (! $this->duration_ms) {
return '-';
}
if ($this->duration_ms < 1000) {
return $this->duration_ms.'ms';
}
return round($this->duration_ms / 1000, 2).'s';
}
/**
* Get actor display name.
*/
public function getActorDisplay(): string
{
return match ($this->actor_type) {
self::ACTOR_USER => "User #{$this->actor_id}",
self::ACTOR_API_KEY => "API Key #{$this->actor_id}",
self::ACTOR_SYSTEM => 'System',
default => 'Unknown',
};
}
/**
* Check if this entry has integrity issues.
*/
public function hasIntegrityIssues(): bool
{
return ! $this->verifyHash() || ! $this->verifyChainLink();
}
/**
* Get integrity status.
*/
public function getIntegrityStatus(): array
{
$hashValid = $this->verifyHash();
$chainValid = $this->verifyChainLink();
return [
'valid' => $hashValid && $chainValid,
'hash_valid' => $hashValid,
'chain_valid' => $chainValid,
'issues' => array_filter([
! $hashValid ? 'Entry hash mismatch - data may have been tampered' : null,
! $chainValid ? 'Chain link broken - previous entry missing or modified' : null,
]),
];
}
/**
* Convert to array for export.
*/
public function toExportArray(): array
{
return [
'id' => $this->id,
'timestamp' => $this->created_at->toIso8601String(),
'server_id' => $this->server_id,
'tool_name' => $this->tool_name,
'workspace_id' => $this->workspace_id,
'session_id' => $this->session_id,
'success' => $this->success,
'duration_ms' => $this->duration_ms,
'error_code' => $this->error_code,
'actor_type' => $this->actor_type,
'actor_id' => $this->actor_id,
'actor_ip' => $this->actor_ip,
'is_sensitive' => $this->is_sensitive,
'sensitivity_reason' => $this->sensitivity_reason,
'entry_hash' => $this->entry_hash,
'previous_hash' => $this->previous_hash,
'agent_type' => $this->agent_type,
'plan_slug' => $this->plan_slug,
];
}
}