Fresh Claude Code agent prompt. M05 must be complete and committed. Estimated effort: small to medium.
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.
Verify M05:
git log --oneline -5
cd api && composer test && composer stan && cd ..
Read SPEC.md §4 (manual_blocks, allowlist tables), §5 ("Manual override semantics" — allowlist precedence, distribution-time evaluation), §6 (Admin API endpoints for these).
Confirm clean tree.
In api/src/Infrastructure/Db/:
ManualBlockRepository.php:
list(?int $limit, ?int $offset, array $filters): array<ManualBlock>findById(int): ?ManualBlockcreate(ManualBlock): ManualBlockdelete(int): voidfindExpired(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.In api/src/Domain/Reputation/CidrEvaluator.php:
manual_blocks (subnet kind only) and allowlist (subnet kind only) on construction.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.[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:
CIDR_EVALUATOR_TTL_SECONDS (default 60s) — gives a near-realtime view without hammering the DB on every blocklist request.invalidate() — called from the manual-block / allowlist mutation endpoints so changes are visible immediately.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}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:
203.0.113.42, subnet 203.0.113.0/24.2001:db8::1, subnet 2001:db8::/32.::ffff:203.0.113.42 should round-trip cleanly.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.For now this milestone implements only the allowlisted and manually_blocked checks. Score-vs-policy comes in M07.
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.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).allowlisted not manually_blocked not scored.manualBlockedSubnets()./api/v1/blocklist) — M07.expires_at and the repository has the query, but the cleanup job itself is not required this milestone.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).
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
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.
Stop. Do not start M07.