M06-manual-blocks-allowlist.md 10 KB

M06 — Manual Blocks, Allowlist, Subnets

Fresh Claude Code agent prompt. M05 must be complete and committed. Estimated effort: small to medium.

Mission

Implement admin endpoints for manual blocks (single IPs and CIDR subnets) and the allowlist. Build the in-memory CIDR containment evaluator that the distribution endpoint will use in M07. Allowlist always wins.

Before you start

  1. Verify M05:

    git log --oneline -5
    cd api && composer test && composer stan && cd ..
    
  2. Read SPEC.md §4 (manual_blocks, allowlist tables), §5 ("Manual override semantics" — allowlist precedence, distribution-time evaluation), §6 (Admin API endpoints for these).

  3. Confirm clean tree.

Tasks

1. Repositories

In api/src/Infrastructure/Db/:

  • ManualBlockRepository.php:
    • list(?int $limit, ?int $offset, array $filters): array<ManualBlock>
    • findById(int): ?ManualBlock
    • create(ManualBlock): ManualBlock
    • delete(int): void
    • findExpired(DateTimeImmutable $now): array<int> — returns ids of expired entries (used by a future cleanup job; skip the job itself for now, just have the query).
  • AllowlistRepository.php — same shape, no expires_at.

ManualBlock value object: id, kind (ip | subnet), ipBin?, networkBin?, prefixLength?, reason, expiresAt?, createdAt, createdByUserId?. AllowlistEntry value object: like above, no expiresAt.

Validation rules (enforced in service layer):

  • kind=ip requires ip and forbids network/prefix_length.
  • kind=subnet requires network (CIDR string) and computes network_bin + prefix_length. Reject if the address part doesn't match the prefix (i.e., reject 203.0.113.5/24 — accept only the canonical network address 203.0.113.0/24). Or normalize automatically; pick one and document. Recommended: normalize automatically and warn in the response if the input wasn't canonical.

2. Domain service: containment evaluator

In api/src/Domain/Reputation/CidrEvaluator.php:

  • Loaded with the current set of manual_blocks (subnet kind only) and allowlist (subnet kind only) on construction.
  • Methods:
    • isAllowlisted(IpAddress $ip): bool — checks both single-IP allowlist entries and subnet entries.
    • isManuallyBlocked(IpAddress $ip): bool — same, for manual_blocks.
    • manualBlockedSubnets(): array<Cidr> — for the distribution endpoint to emit as CIDR lines.
    • allowlistedSubnets(): array<Cidr> — exposed for diagnostics.
  • Implementation: subnet entries are stored as [networkBin, prefixLength] pairs. Containment check is a bitwise prefix match — implement as a small helper using PHP's binary string ops. For up to ~10k entries this is fine in PHP; document the limit.

In api/src/Infrastructure/Reputation/CidrEvaluatorFactory.php:

  • Builds the evaluator from the current DB state.
  • Caches in-process for CIDR_EVALUATOR_TTL_SECONDS (default 60s) — gives a near-realtime view without hammering the DB on every blocklist request.
  • Provides invalidate() — called from the manual-block / allowlist mutation endpoints so changes are visible immediately.

3. Admin endpoints

In api/src/Application/Admin/:

  • ManualBlocksController.php:
    • GET /api/v1/admin/manual-blocks — list, paginated, filterable by kind.
    • GET /api/v1/admin/manual-blocks/{id} — detail.
    • POST /api/v1/admin/manual-blocks — body for IP: {kind:"ip", ip, reason, expires_at?}. Body for subnet: {kind:"subnet", cidr, reason, expires_at?}.
    • DELETE /api/v1/admin/manual-blocks/{id}
    • RBAC: Operator for create/delete, Viewer for list/get.
  • AllowlistController.php — analogous, no expires_at field.

After any successful POST or DELETE, call CidrEvaluatorFactory::invalidate().

Both v4 and v6 must work end-to-end. Test:

  • IP 203.0.113.42, subnet 203.0.113.0/24.
  • IP 2001:db8::1, subnet 2001:db8::/32.
  • IPv4-mapped-v6 quirks: ::ffff:203.0.113.42 should round-trip cleanly.

4. Effective-status helper

In api/src/Domain/Reputation/EffectiveStatusService.php:

  • forIp(IpAddress $ip): EffectiveStatus — returns one of: allowlisted, manually_blocked, scored, clean. Used by the upcoming admin "ip detail" endpoint and the distribution endpoint.
  • Resolution order: allowlisted (any match) → manually blocked (any match) → has scores above any policy threshold (M07 will use this) → clean.

For now this milestone implements only the allowlisted and manually_blocked checks. Score-vs-policy comes in M07.

Implementation notes

  • Allowlist precedence: when an IP matches BOTH the allowlist AND a manual block, allowlist wins. Log a WARNING level entry: "IP X is on both allowlist and manual block list; allowlist takes precedence". Don't reject the configuration — admins are allowed to do this, it's just suspicious.
  • CIDR canonicalization: 203.0.113.5/24 and 203.0.113.0/24 should be treated as the same network. Pick one of: (a) reject non-canonical with 400, (b) silently canonicalize, (c) canonicalize and include a normalized_from field in the response. Recommended (c).
  • Performance: linear scan over subnet lists is fine for this milestone. If the user has 100k subnets we have bigger problems. Don't over-engineer with tries / radix trees.
  • Cache invalidation: the in-process cache is per-replica. With multi-replica deployments, invalidation in one replica doesn't hit others, so they may serve stale evaluator state for up to TTL seconds. Acceptable for this milestone; document.
  • Tests:
    • Both v4 and v6 paths.
    • An IP inside an allowlisted /24 with high reputation score (we can simulate a high score in the DB) is allowlisted not manually_blocked not scored.
    • A /16 manual block produces a single CIDR entry in evaluator's manualBlockedSubnets().
    • Removing a manual block via DELETE actually drops it from the evaluator.

Out of scope (DO NOT)

  • Distribution endpoint (/api/v1/blocklist) — M07.
  • Policy-vs-score evaluation — M07.
  • UI changes.
  • Automatic subnet aggregation. Per SPEC §15, manual only. Don't infer subnets from many bad IPs.
  • Background job for expiring manual blocks. The data model has expires_at and the repository has the query, but the cleanup job itself is not required this milestone.
  • Audit emission — M12.
  • New dependencies.

Acceptance

cd api && composer cs && composer stan && composer test && cd ..

docker compose down -v
cp .env.example .env
docker compose up -d
sleep 15

ADMIN_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=admin --quiet)

# Create a single-IP manual block
curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"kind":"ip","ip":"198.51.100.5","reason":"manual block test"}' \
  http://localhost:8081/api/v1/admin/manual-blocks | grep -q '"id"'

# Create a subnet manual block (canonical)
curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"kind":"subnet","cidr":"198.51.100.0/24","reason":"subnet block"}' \
  http://localhost:8081/api/v1/admin/manual-blocks | grep -q '"prefix_length":24'

# Create a subnet manual block (non-canonical → normalized in response)
RESP=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"kind":"subnet","cidr":"203.0.113.55/24","reason":"non canonical"}' \
  http://localhost:8081/api/v1/admin/manual-blocks)
echo "$RESP" | grep -q '"normalized_from":"203.0.113.55/24"'

# IPv6 subnet
curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"kind":"subnet","cidr":"2001:db8::/32","reason":"v6 test"}' \
  http://localhost:8081/api/v1/admin/manual-blocks | grep -q '"prefix_length":32'

# Allowlist for a known monitoring IP
curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"kind":"ip","ip":"198.51.100.5","reason":"my monitor"}' \
  http://localhost:8081/api/v1/admin/allowlist | grep -q '"id"'

# Allowlist a subnet
curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"kind":"subnet","cidr":"10.0.0.0/8","reason":"private space"}' \
  http://localhost:8081/api/v1/admin/allowlist | grep -q '"id"'

# Check log warns about overlap (allowlist + manual block on 198.51.100.5)
docker compose logs api 2>&1 | grep -q "allowlist takes precedence"

# Listing returns entries
curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
  http://localhost:8081/api/v1/admin/manual-blocks | grep -q '"items"'

# Operator can mutate, Viewer cannot
VIEWER_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=viewer --quiet)
test "$(curl -s -o /dev/null -w '%{http_code}' \
  -X POST -H "Authorization: Bearer $VIEWER_TOKEN" -H "Content-Type: application/json" \
  -d '{"kind":"ip","ip":"1.2.3.4","reason":"x"}' \
  http://localhost:8081/api/v1/admin/manual-blocks)" = "403"

docker compose down -v

Plus PHPUnit tests covering the CidrEvaluator (containment math) and the EffectiveStatusService (precedence rules).

Handoff

  1. Commit:

    feat(M06): manual blocks, allowlist, CIDR evaluator
    
    - admin endpoints for manual_blocks and allowlist (IP and CIDR, v4 + v6)
    - non-canonical CIDR input auto-normalized; response includes normalized_from
    - in-process CidrEvaluator with 60s cache + invalidation on writes
    - EffectiveStatusService skeleton (allowlist + manual; score+policy lands in M07)
    - allowlist always wins; warning logged on overlap with manual blocks
    
  2. Append to PROGRESS.md:

    ## M06 — Manual blocks, allowlist (done)
    
    **Built:** CRUD for manual_blocks and allowlist; CidrEvaluator with cache; EffectiveStatusService (partial).
    
    **Notes for next milestone:**
    - M07 wires CidrEvaluator into the distribution endpoint and finishes EffectiveStatusService with policy evaluation.
    - Cache TTL is 60s; mutation endpoints invalidate explicitly. Multi-replica deployments will see up to 60s of staleness across replicas — documented.
    - Manual block expiration cleanup job is NOT implemented; the data model supports it, the repository has findExpired, but no job runs. Add in M14 hardening if desired, or leave as known limitation.
    
    **Deviations from SPEC:** none.
    **Added dependencies:** none.
    
  3. Stop. Do not start M07.