# 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<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

```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.
