# 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: ```bash 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` - `findById(int): ?ManualBlock` - `create(ManualBlock): ManualBlock` - `delete(int): void` - `findExpired(DateTimeImmutable $now): array` — 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` — for the distribution endpoint to emit as CIDR lines. - `allowlistedSubnets(): array` — 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 ```bash 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`: ```markdown ## 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.