2026-01-27 00:28:29 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
2026-01-27 16:12:58 +00:00
|
|
|
namespace Core\Mod\Agentic\Models;
|
2026-01-27 00:28:29 +00:00
|
|
|
|
2026-01-27 17:34:46 +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;
|
|
|
|
|
|
2026-01-29 13:36:53 +00:00
|
|
|
// Generate a random key with prefix for identification
|
2026-01-27 00:28:29 +00:00
|
|
|
$plainKey = 'ak_'.Str::random(32);
|
|
|
|
|
|
2026-01-29 13:36:53 +00:00
|
|
|
// 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,
|
2026-01-29 13:36:53 +00:00
|
|
|
'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.
|
2026-01-29 13:36:53 +00:00
|
|
|
*
|
|
|
|
|
* 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
|
|
|
|
|
{
|
2026-01-29 13:36:53 +00:00
|
|
|
// 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
|
|
|
|
2026-01-29 13:36:53 +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(),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
}
|