php-agentic/Models/AgentApiKey.php

509 lines
13 KiB
PHP
Raw Permalink Normal View History

2026-01-27 00:28:29 +00:00
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Models;
2026-01-27 00:28:29 +00:00
use Core\Tenant\Models\Workspace;
2026-01-27 00:28:29 +00:00
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
/**
* Agent API Key - enables external agent access to Host Hub.
*
* Keys are hashed for storage. The plaintext key is only
* available once during creation.
*
* @property int $id
* @property int $workspace_id
* @property string $name
* @property string $key
* @property array $permissions
* @property int $rate_limit
* @property int $call_count
* @property \Carbon\Carbon|null $last_used_at
* @property \Carbon\Carbon|null $expires_at
* @property \Carbon\Carbon|null $revoked_at
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
* @property bool $ip_restriction_enabled
* @property array|null $ip_whitelist
* @property string|null $last_used_ip
*/
class AgentApiKey extends Model
{
protected $fillable = [
'workspace_id',
'name',
'key',
'permissions',
'rate_limit',
'call_count',
'last_used_at',
'expires_at',
'revoked_at',
'ip_restriction_enabled',
'ip_whitelist',
'last_used_ip',
];
protected $casts = [
'permissions' => 'array',
'rate_limit' => 'integer',
'call_count' => 'integer',
'last_used_at' => 'datetime',
'expires_at' => 'datetime',
'revoked_at' => 'datetime',
'ip_restriction_enabled' => 'boolean',
'ip_whitelist' => 'array',
];
protected $hidden = [
'key',
];
/**
* The plaintext key (only available after creation).
*/
public ?string $plainTextKey = null;
// Permission constants
public const PERM_PLANS_READ = 'plans.read';
public const PERM_PLANS_WRITE = 'plans.write';
public const PERM_PHASES_WRITE = 'phases.write';
public const PERM_SESSIONS_READ = 'sessions.read';
public const PERM_SESSIONS_WRITE = 'sessions.write';
public const PERM_TOOLS_READ = 'tools.read';
public const PERM_TEMPLATES_READ = 'templates.read';
public const PERM_TEMPLATES_INSTANTIATE = 'templates.instantiate';
// Notify module permissions
public const PERM_NOTIFY_READ = 'notify:read';
public const PERM_NOTIFY_WRITE = 'notify:write';
public const PERM_NOTIFY_SEND = 'notify:send';
/**
* All available permissions with descriptions.
*/
public static function availablePermissions(): array
{
return [
self::PERM_PLANS_READ => 'List and view plans',
self::PERM_PLANS_WRITE => 'Create, update, archive plans',
self::PERM_PHASES_WRITE => 'Update phase status, add/complete tasks',
self::PERM_SESSIONS_READ => 'List and view sessions',
self::PERM_SESSIONS_WRITE => 'Start, update, complete sessions',
self::PERM_TOOLS_READ => 'View tool analytics',
self::PERM_TEMPLATES_READ => 'List and view templates',
self::PERM_TEMPLATES_INSTANTIATE => 'Create plans from templates',
// Notify module
self::PERM_NOTIFY_READ => 'List and view push campaigns, subscribers, and websites',
self::PERM_NOTIFY_WRITE => 'Create, update, and delete campaigns and subscribers',
self::PERM_NOTIFY_SEND => 'Send push notifications immediately or schedule sends',
];
}
// Relationships
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
// Scopes
public function scopeActive($query)
{
return $query->whereNull('revoked_at')
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
}
public function scopeForWorkspace($query, Workspace|int $workspace)
{
$workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace;
return $query->where('workspace_id', $workspaceId);
}
public function scopeRevoked($query)
{
return $query->whereNotNull('revoked_at');
}
public function scopeExpired($query)
{
return $query->whereNotNull('expires_at')
->where('expires_at', '<=', now());
}
// Factory
public static function generate(
Workspace|int $workspace,
string $name,
array $permissions = [],
int $rateLimit = 100,
?\Carbon\Carbon $expiresAt = null
): self {
$workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace;
// Generate a random key with prefix for identification
2026-01-27 00:28:29 +00:00
$plainKey = 'ak_'.Str::random(32);
// Hash using Argon2id for secure storage
// This provides protection against rainbow table attacks and brute force
$hashedKey = password_hash($plainKey, PASSWORD_ARGON2ID, [
'memory_cost' => PASSWORD_ARGON2_DEFAULT_MEMORY_COST,
'time_cost' => PASSWORD_ARGON2_DEFAULT_TIME_COST,
'threads' => PASSWORD_ARGON2_DEFAULT_THREADS,
]);
2026-01-27 00:28:29 +00:00
$key = static::create([
'workspace_id' => $workspaceId,
'name' => $name,
'key' => $hashedKey,
2026-01-27 00:28:29 +00:00
'permissions' => $permissions,
'rate_limit' => $rateLimit,
'call_count' => 0,
'expires_at' => $expiresAt,
]);
// Store plaintext key for one-time display
$key->plainTextKey = $plainKey;
return $key;
}
/**
* Find a key by its plaintext value.
*
* Note: This requires iterating through all active keys since Argon2id
* produces unique hashes with embedded salts. Keys are filtered by prefix
* first for efficiency.
2026-01-27 00:28:29 +00:00
*/
public static function findByKey(string $plainKey): ?self
{
// Early return for obviously invalid keys
if (! str_starts_with($plainKey, 'ak_') || strlen($plainKey) < 10) {
return null;
}
// Get all active keys and verify against each
// This is necessary because Argon2id uses unique salts per hash
$keys = static::whereNull('revoked_at')
->where(function ($query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->get();
foreach ($keys as $key) {
if (password_verify($plainKey, $key->key)) {
return $key;
}
}
return null;
}
2026-01-27 00:28:29 +00:00
/**
* Verify if a plaintext key matches this key's hash.
*/
public function verifyKey(string $plainKey): bool
{
return password_verify($plainKey, $this->key);
2026-01-27 00:28:29 +00:00
}
// Status helpers
public function isActive(): bool
{
if ($this->revoked_at !== null) {
return false;
}
if ($this->expires_at !== null && $this->expires_at->isPast()) {
return false;
}
return true;
}
public function isRevoked(): bool
{
return $this->revoked_at !== null;
}
public function isExpired(): bool
{
return $this->expires_at !== null && $this->expires_at->isPast();
}
// Permission helpers
public function hasPermission(string $permission): bool
{
return in_array($permission, $this->permissions ?? []);
}
public function hasAnyPermission(array $permissions): bool
{
foreach ($permissions as $permission) {
if ($this->hasPermission($permission)) {
return true;
}
}
return false;
}
public function hasAllPermissions(array $permissions): bool
{
foreach ($permissions as $permission) {
if (! $this->hasPermission($permission)) {
return false;
}
}
return true;
}
// Actions
public function revoke(): self
{
$this->update(['revoked_at' => now()]);
return $this;
}
public function recordUsage(): self
{
$this->increment('call_count');
$this->update(['last_used_at' => now()]);
return $this;
}
public function updatePermissions(array $permissions): self
{
$this->update(['permissions' => $permissions]);
return $this;
}
public function updateRateLimit(int $rateLimit): self
{
$this->update(['rate_limit' => $rateLimit]);
return $this;
}
public function extendExpiry(\Carbon\Carbon $expiresAt): self
{
$this->update(['expires_at' => $expiresAt]);
return $this;
}
public function removeExpiry(): self
{
$this->update(['expires_at' => null]);
return $this;
}
// IP Restriction helpers
/**
* Enable IP restrictions for this key.
*/
public function enableIpRestriction(): self
{
$this->update(['ip_restriction_enabled' => true]);
return $this;
}
/**
* Disable IP restrictions for this key.
*/
public function disableIpRestriction(): self
{
$this->update(['ip_restriction_enabled' => false]);
return $this;
}
/**
* Update the IP whitelist.
*
* @param array<string> $whitelist
*/
public function updateIpWhitelist(array $whitelist): self
{
$this->update(['ip_whitelist' => $whitelist]);
return $this;
}
/**
* Add an IP or CIDR to the whitelist.
*/
public function addToIpWhitelist(string $ipOrCidr): self
{
$whitelist = $this->ip_whitelist ?? [];
if (! in_array($ipOrCidr, $whitelist, true)) {
$whitelist[] = $ipOrCidr;
$this->update(['ip_whitelist' => $whitelist]);
}
return $this;
}
/**
* Remove an IP or CIDR from the whitelist.
*/
public function removeFromIpWhitelist(string $ipOrCidr): self
{
$whitelist = $this->ip_whitelist ?? [];
$whitelist = array_values(array_filter($whitelist, fn ($entry) => $entry !== $ipOrCidr));
$this->update(['ip_whitelist' => $whitelist]);
return $this;
}
/**
* Record the last used IP address.
*/
public function recordLastUsedIp(string $ip): self
{
$this->update(['last_used_ip' => $ip]);
return $this;
}
/**
* Check if IP restrictions are enabled and configured.
*/
public function hasIpRestrictions(): bool
{
return $this->ip_restriction_enabled && ! empty($this->ip_whitelist);
}
/**
* Get the count of whitelisted entries.
*/
public function getIpWhitelistCount(): int
{
return count($this->ip_whitelist ?? []);
}
// Rate limiting
public function isRateLimited(): bool
{
// Check calls in the last minute
$recentCalls = $this->getRecentCallCount();
return $recentCalls >= $this->rate_limit;
}
public function getRecentCallCount(int $seconds = 60): int
{
// Use Laravel's cache to track calls per minute
// The AgentApiKeyService increments this key on each authenticated request
$cacheKey = "agent_api_key_rate:{$this->id}";
return (int) \Illuminate\Support\Facades\Cache::get($cacheKey, 0);
}
public function getRemainingCalls(): int
{
return max(0, $this->rate_limit - $this->getRecentCallCount());
}
// Display helpers
public function getMaskedKey(): string
{
// Show first 6 chars of the hashed key (not the plaintext)
return 'ak_'.substr($this->key, 0, 6).'...';
}
public function getStatusLabel(): string
{
if ($this->isRevoked()) {
return 'Revoked';
}
if ($this->isExpired()) {
return 'Expired';
}
return 'Active';
}
public function getStatusColor(): string
{
if ($this->isRevoked()) {
return 'red';
}
if ($this->isExpired()) {
return 'amber';
}
return 'green';
}
public function getLastUsedForHumans(): string
{
if (! $this->last_used_at) {
return 'Never';
}
return $this->last_used_at->diffForHumans();
}
public function getExpiresForHumans(): string
{
if (! $this->expires_at) {
return 'Never';
}
if ($this->isExpired()) {
return 'Expired '.$this->expires_at->diffForHumans();
}
return 'Expires '.$this->expires_at->diffForHumans();
}
// Output
public function toArray(): array
{
return [
'id' => $this->id,
'workspace_id' => $this->workspace_id,
'name' => $this->name,
'permissions' => $this->permissions,
'rate_limit' => $this->rate_limit,
'call_count' => $this->call_count,
'last_used_at' => $this->last_used_at?->toIso8601String(),
'expires_at' => $this->expires_at?->toIso8601String(),
'revoked_at' => $this->revoked_at?->toIso8601String(),
'status' => $this->getStatusLabel(),
'ip_restriction_enabled' => $this->ip_restriction_enabled,
'ip_whitelist_count' => $this->getIpWhitelistCount(),
'last_used_ip' => $this->last_used_ip,
'created_at' => $this->created_at?->toIso8601String(),
];
}
}