feat(security): add API key IP whitelisting with CIDR support (P1-004)

- P1-002: API key security tests verified (bcrypt, rotation)
- P1-003: Webhook signature tests verified (HMAC-SHA256)
- P1-004: IP whitelisting with IPv4/IPv6 CIDR support

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-29 13:20:58 +00:00
parent 919f7e1fc1
commit 49c862b6c1
8 changed files with 866 additions and 22 deletions

51
TODO.md
View file

@ -4,22 +4,24 @@
### High Priority ### High Priority
- [ ] **Test Coverage: API Key Security** - Test bcrypt hashing and rotation - [x] **Test Coverage: API Key Security** - Test bcrypt hashing and rotation
- [ ] Test API key creation with bcrypt hashing - [x] Test API key creation with bcrypt hashing
- [ ] Test API key authentication - [x] Test API key authentication
- [ ] Test key rotation with grace period - [x] Test key rotation with grace period
- [ ] Test key revocation - [x] Test key revocation
- [ ] Test scoped key access - [x] Test scoped key access
- **Estimated effort:** 3-4 hours - **Completed:** 29 January 2026
- **File:** `src/Api/Tests/Feature/ApiKeySecurityTest.php`
- [ ] **Test Coverage: Webhook System** - Test delivery and signatures - [x] **Test Coverage: Webhook System** - Test delivery and signatures
- [ ] Test webhook endpoint registration - [x] Test webhook endpoint registration
- [ ] Test HMAC-SHA256 signature generation - [x] Test HMAC-SHA256 signature generation
- [ ] Test signature verification - [x] Test signature verification
- [ ] Test webhook delivery retry logic - [x] Test webhook delivery retry logic
- [ ] Test exponential backoff - [x] Test exponential backoff
- [ ] Test delivery status tracking - [x] Test delivery status tracking
- **Estimated effort:** 4-5 hours - **Completed:** 29 January 2026
- **File:** `src/Api/Tests/Feature/WebhookDeliveryTest.php`
- [ ] **Test Coverage: Rate Limiting** - Test tier-based limits - [ ] **Test Coverage: Rate Limiting** - Test tier-based limits
- [ ] Test per-tier rate limits - [ ] Test per-tier rate limits
@ -141,12 +143,16 @@
### High Priority ### High Priority
- [ ] **Security: API Key IP Whitelisting** - Restrict key usage - [x] **Security: API Key IP Whitelisting** - Restrict key usage
- [ ] Add allowed_ips column to api_keys - [x] Add allowed_ips column to api_keys
- [ ] Validate request IP against whitelist - [x] Validate request IP against whitelist
- [ ] Test with IPv4 and IPv6 - [x] Test with IPv4 and IPv6
- [ ] Add CIDR notation support - [x] Add CIDR notation support
- **Estimated effort:** 3-4 hours - **Completed:** 29 January 2026
- **Files:**
- `src/Api/Migrations/2026_01_29_000000_add_allowed_ips_to_api_keys_table.php`
- `src/Api/Services/IpRestrictionService.php`
- `src/Api/Tests/Feature/ApiKeyIpWhitelistTest.php`
- [ ] **Security: Request Signing** - Prevent replay attacks - [ ] **Security: Request Signing** - Prevent replay attacks
- [ ] Implement timestamp validation - [ ] Implement timestamp validation
@ -242,5 +248,8 @@
- [x] **Rate Limiting** - Tier-based rate limits with usage alerts - [x] **Rate Limiting** - Tier-based rate limits with usage alerts
- [x] **OpenAPI Documentation** - Auto-generated API docs with Swagger/Scalar/ReDoc - [x] **OpenAPI Documentation** - Auto-generated API docs with Swagger/Scalar/ReDoc
- [x] **Documentation** - Complete API package documentation - [x] **Documentation** - Complete API package documentation
- [x] **API Key Security Tests** - Comprehensive bcrypt hashing and rotation tests (P1-002)
- [x] **Webhook System Signature Tests** - HMAC-SHA256 signature verification tests (P1-003)
- [x] **API Key IP Whitelisting** - allowed_ips column with IPv4/IPv6 and CIDR support (P1-004)
*See `changelog/2026/jan/` for completed features.* *See `changelog/2026/jan/` for completed features.*

View file

@ -60,6 +60,9 @@ class Boot extends ServiceProvider
$this->app->singleton(Services\WebhookTemplateService::class); $this->app->singleton(Services\WebhookTemplateService::class);
$this->app->singleton(Services\WebhookSecretRotationService::class); $this->app->singleton(Services\WebhookSecretRotationService::class);
// Register IP restriction service for API key whitelisting
$this->app->singleton(Services\IpRestrictionService::class);
// Register API Documentation provider // Register API Documentation provider
$this->app->register(DocumentationServiceProvider::class); $this->app->register(DocumentationServiceProvider::class);
} }

View file

@ -55,6 +55,7 @@ class ApiKeyFactory extends Factory
'prefix' => $prefix, 'prefix' => $prefix,
'scopes' => [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE], 'scopes' => [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE],
'server_scopes' => null, 'server_scopes' => null,
'allowed_ips' => null,
'last_used_at' => null, 'last_used_at' => null,
'expires_at' => null, 'expires_at' => null,
'grace_period_ends_at' => null, 'grace_period_ends_at' => null,
@ -219,6 +220,18 @@ class ApiKeyFactory extends Factory
]); ]);
} }
/**
* Set IP whitelist restrictions.
*
* @param array<string>|null $ips Array of IP addresses/CIDRs
*/
public function withAllowedIps(?array $ips): static
{
return $this->state(fn (array $attributes) => [
'allowed_ips' => $ips,
]);
}
/** /**
* Create a revoked (soft-deleted) key. * Create a revoked (soft-deleted) key.
*/ */

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Core\Api\Middleware; namespace Core\Api\Middleware;
use Core\Api\Models\ApiKey; use Core\Api\Models\ApiKey;
use Core\Api\Services\IpRestrictionService;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@ -59,6 +60,16 @@ class AuthenticateApiKey
return $this->unauthorized('API key has expired'); return $this->unauthorized('API key has expired');
} }
// Check IP whitelist if restrictions are enabled
if ($apiKey->hasIpRestrictions()) {
$ipService = app(IpRestrictionService::class);
$requestIp = $request->ip();
if (! $ipService->isIpAllowed($requestIp, $apiKey->getAllowedIps() ?? [])) {
return $this->forbidden('IP address not allowed for this API key');
}
}
// Check scope if required // Check scope if required
if ($scope !== null && ! $apiKey->hasScope($scope)) { if ($scope !== null && ! $apiKey->hasScope($scope)) {
return $this->forbidden("API key missing required scope: {$scope}"); return $this->forbidden("API key missing required scope: {$scope}");

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* Adds IP whitelisting support to API keys:
* - allowed_ips: JSON array of IP addresses and/or CIDR ranges
*
* When allowed_ips is null or empty, no IP restrictions apply.
* When populated, only requests from whitelisted IPs are accepted.
*
* Supports both IPv4 and IPv6 addresses and CIDR notation:
* - Individual IPs: "192.168.1.1", "::1"
* - CIDR ranges: "192.168.0.0/24", "2001:db8::/32"
*/
public function up(): void
{
Schema::table('api_keys', function (Blueprint $table) {
$table->json('allowed_ips')
->nullable()
->after('server_scopes')
->comment('IP whitelist: null=all IPs allowed, ["192.168.1.0/24"]=specific');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('api_keys', function (Blueprint $table) {
$table->dropColumn('allowed_ips');
});
}
};

View file

@ -64,6 +64,7 @@ class ApiKey extends Model
'prefix', 'prefix',
'scopes', 'scopes',
'server_scopes', 'server_scopes',
'allowed_ips',
'last_used_at', 'last_used_at',
'expires_at', 'expires_at',
'grace_period_ends_at', 'grace_period_ends_at',
@ -73,6 +74,7 @@ class ApiKey extends Model
protected $casts = [ protected $casts = [
'scopes' => 'array', 'scopes' => 'array',
'server_scopes' => 'array', 'server_scopes' => 'array',
'allowed_ips' => 'array',
'last_used_at' => 'datetime', 'last_used_at' => 'datetime',
'expires_at' => 'datetime', 'expires_at' => 'datetime',
'grace_period_ends_at' => 'datetime', 'grace_period_ends_at' => 'datetime',
@ -208,9 +210,10 @@ class ApiKey extends Model
$this->expires_at $this->expires_at
); );
// Copy server scopes to new key // Copy server scopes and IP restrictions to new key
$result['api_key']->update([ $result['api_key']->update([
'server_scopes' => $this->server_scopes, 'server_scopes' => $this->server_scopes,
'allowed_ips' => $this->allowed_ips,
'rotated_from_id' => $this->id, 'rotated_from_id' => $this->id,
]); ]);
@ -312,6 +315,57 @@ class ApiKey extends Model
return $this->server_scopes; return $this->server_scopes;
} }
/**
* Check if this key has IP restrictions enabled.
*/
public function hasIpRestrictions(): bool
{
return ! empty($this->allowed_ips);
}
/**
* Get the allowed IPs list (null = all IPs allowed).
*
* @return array<string>|null
*/
public function getAllowedIps(): ?array
{
return $this->allowed_ips;
}
/**
* Update the IP whitelist.
*
* @param array<string>|null $ips Array of IP addresses/CIDRs, or null to allow all
*/
public function updateAllowedIps(?array $ips): void
{
$this->update(['allowed_ips' => $ips]);
}
/**
* Add an IP or CIDR to the whitelist.
*/
public function addAllowedIp(string $ipOrCidr): void
{
$whitelist = $this->allowed_ips ?? [];
if (! in_array($ipOrCidr, $whitelist, true)) {
$whitelist[] = $ipOrCidr;
$this->update(['allowed_ips' => $whitelist]);
}
}
/**
* Remove an IP or CIDR from the whitelist.
*/
public function removeAllowedIp(string $ipOrCidr): void
{
$whitelist = $this->allowed_ips ?? [];
$whitelist = array_values(array_filter($whitelist, fn ($entry) => $entry !== $ipOrCidr));
$this->update(['allowed_ips' => empty($whitelist) ? null : $whitelist]);
}
/** /**
* Revoke this API key. * Revoke this API key.
*/ */

View file

@ -0,0 +1,308 @@
<?php
declare(strict_types=1);
namespace Core\Api\Services;
/**
* IP Restriction Service.
*
* Validates IP addresses against API key whitelists.
* Supports individual IPs and CIDR notation for both IPv4 and IPv6.
*/
class IpRestrictionService
{
/**
* Check if an IP address is in a whitelist.
*
* Supports:
* - Individual IPv4 addresses (192.168.1.1)
* - Individual IPv6 addresses (::1, 2001:db8::1)
* - CIDR notation for IPv4 (192.168.1.0/24)
* - CIDR notation for IPv6 (2001:db8::/32)
*
* @param array<string> $whitelist
*/
public function isIpAllowed(string $ip, array $whitelist): bool
{
$ip = trim($ip);
// Empty whitelist means no restrictions
if (empty($whitelist)) {
return true;
}
// Validate the request IP is a valid IP address
if (! filter_var($ip, FILTER_VALIDATE_IP)) {
return false;
}
foreach ($whitelist as $entry) {
$entry = trim($entry);
if (empty($entry)) {
continue;
}
// Check for CIDR notation
if (str_contains($entry, '/')) {
if ($this->ipMatchesCidr($ip, $entry)) {
return true;
}
} else {
// Exact IP match (normalise both for comparison)
if ($this->normaliseIp($ip) === $this->normaliseIp($entry)) {
return true;
}
}
}
return false;
}
/**
* Check if an IP matches a CIDR range.
*/
public function ipMatchesCidr(string $ip, string $cidr): bool
{
$parts = explode('/', $cidr, 2);
if (count($parts) !== 2) {
return false;
}
[$range, $prefix] = $parts;
$prefix = (int) $prefix;
// Validate both IPs
if (! filter_var($ip, FILTER_VALIDATE_IP)) {
return false;
}
if (! filter_var($range, FILTER_VALIDATE_IP)) {
return false;
}
$isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
$isRangeIpv6 = filter_var($range, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
// IP version must match
if ($isIpv6 !== $isRangeIpv6) {
return false;
}
if ($isIpv6) {
return $this->ipv6MatchesCidr($ip, $range, $prefix);
}
return $this->ipv4MatchesCidr($ip, $range, $prefix);
}
/**
* Check if an IPv4 address matches a CIDR range.
*/
protected function ipv4MatchesCidr(string $ip, string $range, int $prefix): bool
{
// Validate prefix length
if ($prefix < 0 || $prefix > 32) {
return false;
}
$ipLong = ip2long($ip);
$rangeLong = ip2long($range);
if ($ipLong === false || $rangeLong === false) {
return false;
}
// Create the subnet mask
$mask = -1 << (32 - $prefix);
// Apply mask and compare
return ($ipLong & $mask) === ($rangeLong & $mask);
}
/**
* Check if an IPv6 address matches a CIDR range.
*/
protected function ipv6MatchesCidr(string $ip, string $range, int $prefix): bool
{
// Validate prefix length
if ($prefix < 0 || $prefix > 128) {
return false;
}
// Convert to binary representation
$ipBin = $this->ipv6ToBinary($ip);
$rangeBin = $this->ipv6ToBinary($range);
if ($ipBin === null || $rangeBin === null) {
return false;
}
// Compare the first $prefix bits
$prefixBytes = (int) floor($prefix / 8);
$remainingBits = $prefix % 8;
// Compare full bytes
if (substr($ipBin, 0, $prefixBytes) !== substr($rangeBin, 0, $prefixBytes)) {
return false;
}
// Compare remaining bits if any
if ($remainingBits > 0) {
$mask = 0xFF << (8 - $remainingBits);
$ipByte = ord($ipBin[$prefixBytes]);
$rangeByte = ord($rangeBin[$prefixBytes]);
if (($ipByte & $mask) !== ($rangeByte & $mask)) {
return false;
}
}
return true;
}
/**
* Convert an IPv6 address to its binary representation.
*/
protected function ipv6ToBinary(string $ip): ?string
{
$packed = inet_pton($ip);
if ($packed === false) {
return null;
}
return $packed;
}
/**
* Normalise an IP address for comparison.
*
* - IPv4: No change needed
* - IPv6: Expand to full form for consistent comparison
*/
public function normaliseIp(string $ip): string
{
$ip = trim($ip);
// Try to pack and unpack for normalisation
$packed = inet_pton($ip);
if ($packed === false) {
return $ip; // Return original if invalid
}
// inet_ntop will return normalised form
$normalised = inet_ntop($packed);
return $normalised !== false ? $normalised : $ip;
}
/**
* Validate an IP address or CIDR notation.
*
* @return array{valid: bool, error: ?string}
*/
public function validateEntry(string $entry): array
{
$entry = trim($entry);
if (empty($entry)) {
return ['valid' => false, 'error' => 'Empty entry'];
}
// Check for CIDR notation
if (str_contains($entry, '/')) {
return $this->validateCidr($entry);
}
// Validate as plain IP
if (! filter_var($entry, FILTER_VALIDATE_IP)) {
return ['valid' => false, 'error' => 'Invalid IP address'];
}
return ['valid' => true, 'error' => null];
}
/**
* Validate CIDR notation.
*
* @return array{valid: bool, error: ?string}
*/
public function validateCidr(string $cidr): array
{
$parts = explode('/', $cidr, 2);
if (count($parts) !== 2) {
return ['valid' => false, 'error' => 'Invalid CIDR notation'];
}
[$ip, $prefix] = $parts;
// Validate IP portion
if (! filter_var($ip, FILTER_VALIDATE_IP)) {
return ['valid' => false, 'error' => 'Invalid IP address in CIDR'];
}
// Validate prefix is numeric
if (! is_numeric($prefix)) {
return ['valid' => false, 'error' => 'Invalid prefix length'];
}
$prefix = (int) $prefix;
$isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
// Validate prefix range
if ($isIpv6) {
if ($prefix < 0 || $prefix > 128) {
return ['valid' => false, 'error' => 'IPv6 prefix must be between 0 and 128'];
}
} else {
if ($prefix < 0 || $prefix > 32) {
return ['valid' => false, 'error' => 'IPv4 prefix must be between 0 and 32'];
}
}
return ['valid' => true, 'error' => null];
}
/**
* Parse a multi-line string of IPs/CIDRs into an array.
*
* @return array{entries: array<string>, errors: array<string>}
*/
public function parseWhitelistInput(string $input): array
{
$lines = preg_split('/[\r\n,]+/', $input);
$entries = [];
$errors = [];
foreach ($lines as $line) {
$line = trim($line);
if (empty($line)) {
continue;
}
// Skip comments
if (str_starts_with($line, '#')) {
continue;
}
$validation = $this->validateEntry($line);
if ($validation['valid']) {
$entries[] = $line;
} else {
$errors[] = "{$line}: {$validation['error']}";
}
}
return [
'entries' => $entries,
'errors' => $errors,
];
}
}

View file

@ -0,0 +1,403 @@
<?php
declare(strict_types=1);
use Core\Api\Services\IpRestrictionService;
use Illuminate\Support\Facades\Cache;
use Core\Api\Models\ApiKey;
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
Cache::flush();
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create();
$this->workspace->users()->attach($this->user->id, [
'role' => 'owner',
'is_default' => true,
]);
$this->ipService = app(IpRestrictionService::class);
});
// ─────────────────────────────────────────────────────────────────────────────
// IP Restriction Service - IPv4
// ─────────────────────────────────────────────────────────────────────────────
describe('IP Restriction Service - IPv4', function () {
it('allows IP when whitelist is empty', function () {
expect($this->ipService->isIpAllowed('192.168.1.1', []))->toBeTrue();
});
it('matches exact IPv4 address', function () {
$whitelist = ['192.168.1.1', '10.0.0.1'];
expect($this->ipService->isIpAllowed('192.168.1.1', $whitelist))->toBeTrue();
expect($this->ipService->isIpAllowed('10.0.0.1', $whitelist))->toBeTrue();
expect($this->ipService->isIpAllowed('192.168.1.2', $whitelist))->toBeFalse();
});
it('matches IPv4 CIDR /24 range', function () {
$whitelist = ['192.168.1.0/24'];
expect($this->ipService->isIpAllowed('192.168.1.0', $whitelist))->toBeTrue();
expect($this->ipService->isIpAllowed('192.168.1.1', $whitelist))->toBeTrue();
expect($this->ipService->isIpAllowed('192.168.1.255', $whitelist))->toBeTrue();
expect($this->ipService->isIpAllowed('192.168.2.1', $whitelist))->toBeFalse();
});
it('matches IPv4 CIDR /16 range', function () {
$whitelist = ['10.0.0.0/16'];
expect($this->ipService->isIpAllowed('10.0.0.1', $whitelist))->toBeTrue();
expect($this->ipService->isIpAllowed('10.0.255.255', $whitelist))->toBeTrue();
expect($this->ipService->isIpAllowed('10.1.0.1', $whitelist))->toBeFalse();
});
it('matches IPv4 CIDR /32 single host', function () {
$whitelist = ['192.168.1.100/32'];
expect($this->ipService->isIpAllowed('192.168.1.100', $whitelist))->toBeTrue();
expect($this->ipService->isIpAllowed('192.168.1.101', $whitelist))->toBeFalse();
});
it('matches IPv4 CIDR /8 class A range', function () {
$whitelist = ['10.0.0.0/8'];
expect($this->ipService->isIpAllowed('10.0.0.1', $whitelist))->toBeTrue();
expect($this->ipService->isIpAllowed('10.255.255.255', $whitelist))->toBeTrue();
expect($this->ipService->isIpAllowed('11.0.0.1', $whitelist))->toBeFalse();
});
it('rejects invalid IPv4 addresses', function () {
$whitelist = ['192.168.1.0/24'];
expect($this->ipService->isIpAllowed('invalid', $whitelist))->toBeFalse();
expect($this->ipService->isIpAllowed('256.256.256.256', $whitelist))->toBeFalse();
expect($this->ipService->isIpAllowed('', $whitelist))->toBeFalse();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// IP Restriction Service - IPv6
// ─────────────────────────────────────────────────────────────────────────────
describe('IP Restriction Service - IPv6', function () {
it('matches exact IPv6 address', function () {
$whitelist = ['::1', '2001:db8::1'];
expect($this->ipService->isIpAllowed('::1', $whitelist))->toBeTrue();
expect($this->ipService->isIpAllowed('2001:db8::1', $whitelist))->toBeTrue();
expect($this->ipService->isIpAllowed('2001:db8::2', $whitelist))->toBeFalse();
});
it('normalises IPv6 for comparison', function () {
$whitelist = ['2001:db8:0000:0000:0000:0000:0000:0001'];
// Shortened form should match expanded form
expect($this->ipService->isIpAllowed('2001:db8::1', $whitelist))->toBeTrue();
});
it('matches IPv6 CIDR /64 range', function () {
$whitelist = ['2001:db8::/64'];
expect($this->ipService->isIpAllowed('2001:db8::1', $whitelist))->toBeTrue();
expect($this->ipService->isIpAllowed('2001:db8::ffff', $whitelist))->toBeTrue();
expect($this->ipService->isIpAllowed('2001:db8:0:1::1', $whitelist))->toBeFalse();
});
it('matches IPv6 CIDR /32 range', function () {
$whitelist = ['2001:db8::/32'];
expect($this->ipService->isIpAllowed('2001:db8::1', $whitelist))->toBeTrue();
expect($this->ipService->isIpAllowed('2001:db8:ffff::1', $whitelist))->toBeTrue();
expect($this->ipService->isIpAllowed('2001:db9::1', $whitelist))->toBeFalse();
});
it('matches IPv6 loopback', function () {
$whitelist = ['::1/128'];
expect($this->ipService->isIpAllowed('::1', $whitelist))->toBeTrue();
expect($this->ipService->isIpAllowed('::2', $whitelist))->toBeFalse();
});
it('does not match IPv4 against IPv6 CIDR', function () {
$whitelist = ['2001:db8::/32'];
expect($this->ipService->isIpAllowed('192.168.1.1', $whitelist))->toBeFalse();
});
it('does not match IPv6 against IPv4 CIDR', function () {
$whitelist = ['192.168.1.0/24'];
expect($this->ipService->isIpAllowed('2001:db8::1', $whitelist))->toBeFalse();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// IP Restriction Service - Validation
// ─────────────────────────────────────────────────────────────────────────────
describe('IP Restriction Service - Validation', function () {
it('validates correct IPv4 addresses', function () {
$result = $this->ipService->validateEntry('192.168.1.1');
expect($result['valid'])->toBeTrue();
expect($result['error'])->toBeNull();
});
it('validates correct IPv6 addresses', function () {
$result = $this->ipService->validateEntry('2001:db8::1');
expect($result['valid'])->toBeTrue();
expect($result['error'])->toBeNull();
});
it('validates correct IPv4 CIDR', function () {
$result = $this->ipService->validateEntry('192.168.1.0/24');
expect($result['valid'])->toBeTrue();
expect($result['error'])->toBeNull();
});
it('validates correct IPv6 CIDR', function () {
$result = $this->ipService->validateEntry('2001:db8::/32');
expect($result['valid'])->toBeTrue();
expect($result['error'])->toBeNull();
});
it('rejects invalid IP addresses', function () {
$result = $this->ipService->validateEntry('not-an-ip');
expect($result['valid'])->toBeFalse();
expect($result['error'])->toBe('Invalid IP address');
});
it('rejects invalid CIDR prefix for IPv4', function () {
$result = $this->ipService->validateEntry('192.168.1.0/33');
expect($result['valid'])->toBeFalse();
expect($result['error'])->toBe('IPv4 prefix must be between 0 and 32');
});
it('rejects invalid CIDR prefix for IPv6', function () {
$result = $this->ipService->validateEntry('2001:db8::/129');
expect($result['valid'])->toBeFalse();
expect($result['error'])->toBe('IPv6 prefix must be between 0 and 128');
});
it('rejects empty entries', function () {
$result = $this->ipService->validateEntry('');
expect($result['valid'])->toBeFalse();
expect($result['error'])->toBe('Empty entry');
});
it('parses multi-line whitelist input', function () {
$input = "192.168.1.1\n10.0.0.0/8\n# Comment line\n2001:db8::1\ninvalid-ip";
$result = $this->ipService->parseWhitelistInput($input);
expect($result['entries'])->toBe(['192.168.1.1', '10.0.0.0/8', '2001:db8::1']);
expect($result['errors'])->toHaveCount(1);
expect($result['errors'][0])->toContain('invalid-ip');
});
it('handles comma-separated whitelist input', function () {
$input = '192.168.1.1, 10.0.0.1, 172.16.0.0/12';
$result = $this->ipService->parseWhitelistInput($input);
expect($result['entries'])->toBe(['192.168.1.1', '10.0.0.1', '172.16.0.0/12']);
expect($result['errors'])->toBeEmpty();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// API Key IP Whitelist Model Methods
// ─────────────────────────────────────────────────────────────────────────────
describe('API Key IP Whitelist Model', function () {
it('reports no restrictions when allowed_ips is null', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'No Restrictions Key'
);
expect($result['api_key']->hasIpRestrictions())->toBeFalse();
expect($result['api_key']->getAllowedIps())->toBeNull();
});
it('reports no restrictions when allowed_ips is empty', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'Empty Whitelist Key'
);
$result['api_key']->update(['allowed_ips' => []]);
expect($result['api_key']->fresh()->hasIpRestrictions())->toBeFalse();
});
it('reports restrictions when allowed_ips has entries', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'Restricted Key'
);
$result['api_key']->update(['allowed_ips' => ['192.168.1.0/24']]);
$key = $result['api_key']->fresh();
expect($key->hasIpRestrictions())->toBeTrue();
expect($key->getAllowedIps())->toBe(['192.168.1.0/24']);
});
it('updates allowed IPs', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'Update IPs Key'
);
$result['api_key']->updateAllowedIps(['10.0.0.0/8', '192.168.1.1']);
expect($result['api_key']->fresh()->getAllowedIps())->toBe(['10.0.0.0/8', '192.168.1.1']);
});
it('adds IP to whitelist', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'Add IP Key'
);
$result['api_key']->update(['allowed_ips' => ['192.168.1.1']]);
$result['api_key']->addAllowedIp('10.0.0.1');
expect($result['api_key']->fresh()->getAllowedIps())->toBe(['192.168.1.1', '10.0.0.1']);
});
it('does not add duplicate IPs', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'Duplicate IP Key'
);
$result['api_key']->update(['allowed_ips' => ['192.168.1.1']]);
$result['api_key']->addAllowedIp('192.168.1.1');
expect($result['api_key']->fresh()->getAllowedIps())->toBe(['192.168.1.1']);
});
it('removes IP from whitelist', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'Remove IP Key'
);
$result['api_key']->update(['allowed_ips' => ['192.168.1.1', '10.0.0.1']]);
$result['api_key']->removeAllowedIp('192.168.1.1');
expect($result['api_key']->fresh()->getAllowedIps())->toBe(['10.0.0.1']);
});
it('sets allowed_ips to null when removing last IP', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'Remove Last IP Key'
);
$result['api_key']->update(['allowed_ips' => ['192.168.1.1']]);
$result['api_key']->removeAllowedIp('192.168.1.1');
expect($result['api_key']->fresh()->getAllowedIps())->toBeNull();
expect($result['api_key']->fresh()->hasIpRestrictions())->toBeFalse();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// API Key Rotation with IP Whitelist
// ─────────────────────────────────────────────────────────────────────────────
describe('API Key Rotation with IP Whitelist', function () {
it('preserves IP whitelist during rotation', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'IP Restricted Key'
);
$result['api_key']->update(['allowed_ips' => ['192.168.1.0/24', '10.0.0.1']]);
$rotated = $result['api_key']->fresh()->rotate();
expect($rotated['api_key']->getAllowedIps())->toBe(['192.168.1.0/24', '10.0.0.1']);
});
it('preserves empty IP whitelist during rotation', function () {
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'No Restrictions Key'
);
$rotated = $result['api_key']->rotate();
expect($rotated['api_key']->getAllowedIps())->toBeNull();
expect($rotated['api_key']->hasIpRestrictions())->toBeFalse();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// API Key Factory IP Whitelist
// ─────────────────────────────────────────────────────────────────────────────
describe('API Key Factory IP Whitelist', function () {
it('creates keys with IP whitelist via factory', function () {
$key = ApiKey::factory()
->for($this->workspace)
->for($this->user)
->withAllowedIps(['192.168.1.0/24', '::1'])
->create();
expect($key->hasIpRestrictions())->toBeTrue();
expect($key->getAllowedIps())->toBe(['192.168.1.0/24', '::1']);
});
it('creates keys without IP restrictions by default', function () {
$key = ApiKey::factory()
->for($this->workspace)
->for($this->user)
->create();
expect($key->hasIpRestrictions())->toBeFalse();
expect($key->getAllowedIps())->toBeNull();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Mixed IP Versions
// ─────────────────────────────────────────────────────────────────────────────
describe('Mixed IP Versions in Whitelist', function () {
it('handles mixed IPv4 and IPv6 entries', function () {
$whitelist = ['192.168.1.0/24', '2001:db8::/32', '10.0.0.1', '::1'];
// IPv4 matching
expect($this->ipService->isIpAllowed('192.168.1.100', $whitelist))->toBeTrue();
expect($this->ipService->isIpAllowed('10.0.0.1', $whitelist))->toBeTrue();
expect($this->ipService->isIpAllowed('172.16.0.1', $whitelist))->toBeFalse();
// IPv6 matching
expect($this->ipService->isIpAllowed('2001:db8::1', $whitelist))->toBeTrue();
expect($this->ipService->isIpAllowed('::1', $whitelist))->toBeTrue();
expect($this->ipService->isIpAllowed('2001:db9::1', $whitelist))->toBeFalse();
});
});