diff --git a/.env.example b/.env.example index d1a413f..57bc523 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,38 @@ +# ============================================================================ +# Cloudflare API Configuration +# ============================================================================ +# For managing Cloudflare IP lists, firewall rules, and Access policies. +# Documentation: https://developers.cloudflare.com/api/ + +# Cloudflare API Token (required for cloudflare-api package) +# Create a token at: https://dash.cloudflare.com/profile/api-tokens +# Required permissions: +# - Account > Account Filter Lists > Edit (for IP lists) +# - Account > Account Firewall Access Rules > Edit (for firewall rules) +CLOUDFLARE_API_TOKEN=your-cloudflare-api-token-here + +# Cloudflare Account ID (required) +# Find this at: https://dash.cloudflare.com/ (right sidebar) +CLOUDFLARE_ACCOUNT_ID=your-cloudflare-account-id-here + +# Cloudflare Zone ID (optional, for zone-scoped operations) +# Find this on your domain's overview page in the dashboard +# CLOUDFLARE_ZONE_ID=your-zone-id-here + +# Cloudflare API Settings (optional) +# CF_DEFAULT_LIST_KIND=ip +# CF_REQUEST_TIMEOUT=30 +# CF_MAX_RETRIES=3 +# CF_BULK_POLL_INTERVAL=1.0 +# CF_BULK_TIMEOUT=300 + +# ============================================================================ +# Cloudflare Access Configuration (for cloudflare-auth package) +# ============================================================================ +# For JWT validation with Cloudflare Access. +# CLOUDFLARE_TEAM_DOMAIN=your-team.cloudflareaccess.com +# CLOUDFLARE_AUDIENCE_TAG=your-application-audience-tag + # ============================================================================ # Google Cloud - Assured OSS Configuration # ============================================================================ diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..f0eada1 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,104 @@ +# .github/workflows/publish.yml +# Publishes packages to GCP Artifact Registry when tags are pushed +# Secrets are fetched from Infisical +# Uses Assured OSS dependencies for supply chain security + +name: Publish Package + +on: + push: + tags: + - 'cloudflare-auth-v*' + - 'gcs-utilities-v*' + +permissions: + contents: read + +env: + INFISICAL_DOMAIN: https://secrets.byronwilliamscpa.com + INFISICAL_PROJECT: python-libs + INFISICAL_ENV: prod + ARTIFACT_REGISTRY_URL: https://us-central1-python.pkg.dev/assured-oss-457903/python-libs/ + +jobs: + determine-package: + runs-on: ubuntu-latest + outputs: + package_dir: ${{ steps.parse.outputs.package_dir }} + package_name: ${{ steps.parse.outputs.package_name }} + version: ${{ steps.parse.outputs.version }} + steps: + - name: Parse tag + id: parse + run: | + TAG="${{ github.ref_name }}" + echo "Processing tag: $TAG" + + if [[ "$TAG" == cloudflare-auth-v* ]]; then + echo "package_dir=packages/cloudflare-auth" >> $GITHUB_OUTPUT + echo "package_name=byronwilliamscpa-cloudflare-auth" >> $GITHUB_OUTPUT + echo "version=${TAG#cloudflare-auth-v}" >> $GITHUB_OUTPUT + elif [[ "$TAG" == gcs-utilities-v* ]]; then + echo "package_dir=packages/gcs-utilities" >> $GITHUB_OUTPUT + echo "package_name=byronwilliamscpa-gcs-utilities" >> $GITHUB_OUTPUT + echo "version=${TAG#gcs-utilities-v}" >> $GITHUB_OUTPUT + else + echo "::error::Unknown tag format: $TAG" + exit 1 + fi + + build-and-publish: + needs: determine-package + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Fetch secrets from Infisical + uses: Infisical/secrets-action@03d3fa38607956c493f53c6633f94006a13c47ae # v1.0.7 + with: + client-id: ${{ secrets.INFISICAL_CLIENT_ID }} + client-secret: ${{ secrets.INFISICAL_CLIENT_SECRET }} + env-slug: ${{ env.INFISICAL_ENV }} + project-slug: ${{ env.INFISICAL_PROJECT }} + domain: ${{ env.INFISICAL_DOMAIN }} + + - name: Install uv + uses: astral-sh/setup-uv@6b9c6063abd6010835644d4c2e1bef4cf5cd0fca # v6.0.1 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.12 + + - name: Verify version matches tag + working-directory: ${{ needs.determine-package.outputs.package_dir }} + run: | + TOML_VERSION=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/') + TAG_VERSION="${{ needs.determine-package.outputs.version }}" + if [[ "$TOML_VERSION" != "$TAG_VERSION" ]]; then + echo "::error::Version mismatch! pyproject.toml=$TOML_VERSION, tag=$TAG_VERSION" + exit 1 + fi + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@71f986410dfbc7added4569d411d040a91dc6935 # v2.1.8 + with: + credentials_json: ${{ env.GCP_SA_KEY_BASE64 }} + + - name: Install keyring for Artifact Registry + run: pip install keyrings.google-artifactregistry-auth + + - name: Build package + working-directory: ${{ needs.determine-package.outputs.package_dir }} + run: uv build + + - name: Publish to Artifact Registry + working-directory: ${{ needs.determine-package.outputs.package_dir }} + run: uv publish --publish-url ${{ env.ARTIFACT_REGISTRY_URL }} + + - name: Job summary + run: | + echo "## πŸ“¦ Published: ${{ needs.determine-package.outputs.package_name }} v${{ needs.determine-package.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Registry: \`us-central1-python.pkg.dev/assured-oss-457903/python-libs\`" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index d08955a..da87ca5 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -131,7 +131,7 @@ jobs: - name: Check Quality Gate if: steps.sonar-scan.outcome == 'success' - uses: sonarsource/sonarqube-quality-gate-action@master + uses: sonarsource/sonarqube-quality-gate-action@cf038b0e0cdecfa9e56c198bbb7d21d751d62c3b # v1.2.0 timeout-minutes: 5 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/validate-cruft.yml b/.github/workflows/validate-cruft.yml index 69a9492..7baf46d 100644 --- a/.github/workflows/validate-cruft.yml +++ b/.github/workflows/validate-cruft.yml @@ -10,6 +10,9 @@ on: - cron: '0 9 * * 1' workflow_dispatch: +permissions: + contents: read + jobs: validate-cruft: runs-on: ubuntu-latest diff --git a/docs/cloudflare-api-handoff.md b/docs/cloudflare-api-handoff.md new file mode 100644 index 0000000..f1af4fe --- /dev/null +++ b/docs/cloudflare-api-handoff.md @@ -0,0 +1,686 @@ +# Cloudflare API Package - Handoff Document + +**Date**: 2025-12-04 +**Repository**: python-libs (ByronWilliamsCPA/python-libs) +**Branch**: `feat/assured-oss-artifact-registry` +**Target Repository**: homelab-infra +**Package Version**: 0.1.0 + +--- + +## Executive Summary + +This document describes the `cloudflare-api` package built for managing Cloudflare IP lists and automating IP range group synchronization. The package was developed in the wrong repository (`python-libs`) but is ready for migration to `homelab-infra`. + +### What Was Built + +1. **Cloudflare API Client** - Python SDK wrapper for managing IP lists, firewall rules, and Access policies +2. **IP Range Groups System** - Configuration-driven system to sync IP ranges from multiple sources (GitHub, GCP, AWS, static IPs) to Cloudflare lists +3. **CLI Tool** - Command-line interface for syncing IP groups +4. **Comprehensive Tests** - 83 tests covering all functionality + +--- + +## Package Structure + +``` +packages/cloudflare-api/ +β”œβ”€β”€ src/cloudflare_api/ +β”‚ β”œβ”€β”€ __init__.py # Main package exports +β”‚ β”œβ”€β”€ client.py # CloudflareAPIClient (IP list CRUD) +β”‚ β”œβ”€β”€ exceptions.py # Custom exception hierarchy +β”‚ β”œβ”€β”€ models.py # Pydantic models (IPList, IPListItem, etc.) +β”‚ β”œβ”€β”€ settings.py # CloudflareAPISettings (env config) +β”‚ └── ip_groups/ # IP Range Groups System +β”‚ β”œβ”€β”€ __init__.py # IP groups exports +β”‚ β”œβ”€β”€ config.py # Configuration models (YAML schema) +β”‚ β”œβ”€β”€ fetchers.py # IP source fetchers (GitHub, GCP, AWS, URL, static) +β”‚ β”œβ”€β”€ manager.py # IPGroupManager (orchestration) +β”‚ └── cli.py # CLI commands +β”œβ”€β”€ tests/ +β”‚ β”œβ”€β”€ test_client.py # Client tests (26 tests) +β”‚ β”œβ”€β”€ test_models.py # Model tests (13 tests) +β”‚ β”œβ”€β”€ test_settings.py # Settings tests (12 tests) +β”‚ └── test_ip_groups.py # IP groups tests (32 tests) +β”œβ”€β”€ pyproject.toml # Package configuration +└── README.md # Package documentation +``` + +--- + +## Core Components + +### 1. Cloudflare API Client ([client.py](file:///home/byron/python-libs/packages/cloudflare-api/src/cloudflare_api/client.py)) + +**Purpose**: High-level wrapper for Cloudflare's official Python SDK + +**Key Methods**: +- `list_ip_lists()` - List all IP lists +- `get_ip_list(list_id)` - Get list details +- `get_ip_list_by_name(name)` - Find list by name +- `create_ip_list(name, kind, description)` - Create new list +- `update_ip_list(list_id, description)` - Update list +- `delete_ip_list(list_id)` - Delete list +- `get_ip_list_items(list_id)` - Get items in list +- `add_ip_list_items(list_id, items)` - Add IPs (async) +- `replace_ip_list_items(list_id, items)` - Replace all IPs (async) +- `delete_ip_list_items(list_id, item_ids)` - Delete IPs (async) +- `ensure_ip_list(name, kind, description)` - Get or create list +- `sync_ip_list(list_id, ips, comments)` - Sync list to exact IPs + +**Authentication**: Uses `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` from environment + +**Error Handling**: Converts Cloudflare SDK exceptions to custom exceptions: +- `CloudflareAuthError` - 401 authentication failures +- `CloudflareRateLimitError` - Rate limit exceeded +- `CloudflareNotFoundError` - 404 not found +- `CloudflareValidationError` - 400 bad request +- `CloudflareConflictError` - 409 conflicts (name exists, list in use) +- `CloudflareBulkOperationError` - Bulk operation failures +- `CloudflareAPIError` - General API errors + +**File Location**: `packages/cloudflare-api/src/cloudflare_api/client.py` (692 lines) + +--- + +### 2. IP Range Groups System + +#### Configuration Schema ([config.py](file:///home/byron/python-libs/packages/cloudflare-api/src/cloudflare_api/ip_groups/config.py)) + +**Purpose**: Define IP groups and their sources in YAML + +**Models**: +- `SourceType` - Enum: `static`, `url`, `github`, `google_cloud`, `aws`, `azure`, `cloudflare` +- `IPSourceConfig` - Configuration for a single IP source +- `IPGroupConfig` - Configuration for an IP group (maps to a Cloudflare list) +- `IPGroupsConfig` - Root configuration with all groups + +**Example Config** ([ip_groups.example.yaml](file:///home/byron/python-libs/ip_groups.example.yaml)): +```yaml +version: "1.0" +cache_ttl_seconds: 3600 +cloudflare_list_prefix: "" + +groups: + - name: github + cloudflare_list_name: github-ips + description: "GitHub Actions and webhook IPs" + enabled: true + sources: + - type: github + services: [actions, hooks, dependabot] + + - name: home-network + cloudflare_list_name: home-ips + description: "Home and office IPs" + enabled: true + sources: + - type: static + ips: ["203.0.113.50", "198.51.100.0/24"] +``` + +**File Location**: `packages/cloudflare-api/src/cloudflare_api/ip_groups/config.py` (132 lines) + +--- + +#### IP Fetchers ([fetchers.py](file:///home/byron/python-libs/packages/cloudflare-api/src/cloudflare_api/ip_groups/fetchers.py)) + +**Purpose**: Fetch IP ranges from various sources + +**Fetcher Classes**: + +| Fetcher | Source | Filters | Notes | +|---------|--------|---------|-------| +| `StaticIPFetcher` | Hardcoded IPs in config | IP version | For home/office networks | +| `URLIPFetcher` | Generic URL (text or JSON) | IP version, JSONPath | For custom endpoints | +| `GitHubIPFetcher` | `https://api.github.com/meta` | Services (actions, hooks, etc.), IP version | GitHub Actions, webhooks | +| `GoogleCloudIPFetcher` | `https://www.gstatic.com/ipranges/cloud.json` | Regions, services, IP version | GCP IP ranges | +| `AWSIPFetcher` | `https://ip-ranges.amazonaws.com/ip-ranges.json` | Regions, services, IP version | AWS IP ranges | +| `CloudflareIPFetcher` | `https://www.cloudflare.com/ips-v4/6` | IP version | Cloudflare's own IPs | + +**Key Features**: +- IP validation (IPv4/IPv6) +- Version filtering (IPv4 only, IPv6 only, or both) +- Region filtering (GCP, AWS) +- Service filtering (GitHub: actions/hooks, AWS: EC2/S3, etc.) +- JSONPath extraction for custom JSON APIs +- Auto-detection of IP fields in JSON responses + +**File Location**: `packages/cloudflare-api/src/cloudflare_api/ip_groups/fetchers.py` (518 lines) + +--- + +#### IP Group Manager ([manager.py](file:///home/byron/python-libs/packages/cloudflare-api/src/cloudflare_api/ip_groups/manager.py)) + +**Purpose**: Orchestrate fetching and syncing IP groups to Cloudflare + +**Key Methods**: +- `from_config(path)` - Load manager from YAML config +- `fetch_source_ips(source)` - Fetch IPs from single source (with caching) +- `fetch_group_ips(group)` - Fetch all IPs for a group (all sources) +- `preview_group(name)` - Preview changes without applying +- `sync_group(name, dry_run)` - Sync a single group to Cloudflare +- `sync_all(dry_run)` - Sync all enabled groups +- `list_groups()` - List configured groups +- `clear_cache()` - Clear cached IP data + +**Caching**: +- Caches fetched IPs per source (default: 1 hour TTL) +- Cache key = hash of source config +- Invalidates on config change or TTL expiration + +**Sync Algorithm**: +1. Fetch IPs from all sources for the group +2. Deduplicate IPs +3. Ensure Cloudflare list exists (create if missing) +4. Get current IPs from Cloudflare +5. Calculate diff (added, removed, unchanged) +6. Replace all items in Cloudflare list if changed +7. Return `SyncResult` with stats + +**File Location**: `packages/cloudflare-api/src/cloudflare_api/ip_groups/manager.py` (443 lines) + +--- + +#### CLI Tool ([cli.py](file:///home/byron/python-libs/packages/cloudflare-api/src/cloudflare_api/ip_groups/cli.py)) + +**Purpose**: Command-line interface for managing IP groups + +**Commands**: + +```bash +# List configured groups +cloudflare-ip-groups list [--json] + +# Preview changes for a group +cloudflare-ip-groups preview [--json] + +# Sync groups to Cloudflare +cloudflare-ip-groups sync [--group ] [--dry-run] + +# Fetch IPs for a group (without syncing) +cloudflare-ip-groups fetch [--json] [--no-cache] +``` + +**Configuration**: +- Default config path: `ip_groups.yaml` +- Override with: `-c/--config ` +- Verbose logging: `-v/--verbose` + +**Entry Point**: Registered as console script in `pyproject.toml`: +```toml +[project.scripts] +cloudflare-ip-groups = "cloudflare_api.ip_groups.cli:main" +``` + +**File Location**: `packages/cloudflare-api/src/cloudflare_api/ip_groups/cli.py` (254 lines) + +--- + +## Dependencies + +### Runtime Dependencies +```toml +dependencies = [ + "cloudflare>=4.0.0", # Official Cloudflare SDK + "pydantic>=2.0.0", # Data validation + "pydantic-settings>=2.0.0", # Settings management + "httpx>=0.25.0", # HTTP client for fetchers + "pyyaml>=6.0.0", # YAML config parsing +] +``` + +### Development Dependencies +```toml +dev = [ + "pytest>=7.4.0", + "pytest-cov>=4.1.0", + "pytest-asyncio>=0.21.0", + "respx>=0.21.0", # HTTP mocking for tests +] +``` + +--- + +## Environment Variables + +```bash +# Required +CLOUDFLARE_API_TOKEN=your-api-token-here +CLOUDFLARE_ACCOUNT_ID=your-account-id-here + +# Optional +CLOUDFLARE_ZONE_ID=your-zone-id # For zone-level operations +CLOUDFLARE_API_EMAIL=email@example.com # Legacy auth (not recommended) +CLOUDFLARE_API_KEY=legacy-key # Legacy auth (not recommended) + +# Optional: Timeouts +CLOUDFLARE_BULK_OPERATION_TIMEOUT=300 # Seconds (default: 300) +CLOUDFLARE_BULK_OPERATION_POLL_INTERVAL=2 # Seconds (default: 2) +``` + +--- + +## Test Coverage + +**Total**: 83 tests, all passing + +### Test Breakdown + +1. **Client Tests** ([test_client.py](file:///home/byron/python-libs/packages/cloudflare-api/tests/test_client.py)) - 26 tests + - IP list CRUD operations + - Item operations (add, replace, delete) + - Bulk operation handling + - Error handling + +2. **Model Tests** ([test_models.py](file:///home/byron/python-libs/packages/cloudflare-api/tests/test_models.py)) - 13 tests + - IPList, IPListItem, BulkOperation models + - Enum validation (ListKind, BulkOperationStatus) + - API dict conversion + +3. **Settings Tests** ([test_settings.py](file:///home/byron/python-libs/packages/cloudflare-api/tests/test_settings.py)) - 12 tests + - Environment variable loading + - Singleton pattern + - Validation + +4. **IP Groups Tests** ([test_ip_groups.py](file:///home/byron/python-libs/packages/cloudflare-api/tests/test_ip_groups.py)) - 32 tests + - Configuration loading + - All fetchers (static, URL, GitHub, GCP, AWS) + - IP validation + - Manager operations + - Caching + +**Run Tests**: +```bash +uv run pytest packages/cloudflare-api/tests/ -v +``` + +--- + +## Usage Examples + +### Basic API Client + +```python +from cloudflare_api import CloudflareAPIClient + +client = CloudflareAPIClient() + +# Create an IP list +ip_list = client.create_ip_list( + name="blocked-ips", + kind="ip", + description="Blocked IP addresses" +) + +# Add IPs +client.add_ip_list_items(ip_list.id, [ + {"ip": "1.2.3.4", "comment": "Spam bot"}, + {"ip": "5.6.7.8/24", "comment": "Bad network"}, +]) + +# Get all items +items = client.get_ip_list_items(ip_list.id) + +# Sync to exact set of IPs +client.sync_ip_list(ip_list.id, ["10.0.0.1", "10.0.0.2"]) +``` + +### IP Groups Configuration + +```python +from cloudflare_api.ip_groups import IPGroupManager + +# Load from config file +manager = IPGroupManager.from_config("ip_groups.yaml") + +# List all groups +groups = manager.list_groups() +for group in groups: + print(f"{group['name']}: {group['enabled']}") + +# Preview changes +preview = manager.preview_group("github") +print(f"Will add: {len(preview['to_add'])} IPs") +print(f"Will remove: {len(preview['to_remove'])} IPs") + +# Sync all groups +results = manager.sync_all() +for result in results: + if result.error: + print(f"❌ {result.group_name}: {result.error}") + else: + print(f"βœ“ {result.group_name}: {result.ips_count} IPs") +``` + +### CLI Usage + +```bash +# Create config file +cp ip_groups.example.yaml ip_groups.yaml +# Edit ip_groups.yaml with your IP groups + +# Set environment variables +export CLOUDFLARE_API_TOKEN=your-token +export CLOUDFLARE_ACCOUNT_ID=your-account-id + +# Preview changes +cloudflare-ip-groups preview github + +# Sync all groups +cloudflare-ip-groups sync + +# Dry run (preview without applying) +cloudflare-ip-groups sync --dry-run +``` + +--- + +## Automation Setup (Cron Job) + +### Example Crontab Entry + +```bash +# Sync IP groups every hour +0 * * * * cd /path/to/homelab-infra && /usr/bin/cloudflare-ip-groups sync >> /var/log/cloudflare-sync.log 2>&1 + +# Sync every 6 hours +0 */6 * * * cd /path/to/homelab-infra && /usr/bin/cloudflare-ip-groups sync + +# Daily sync with notifications +0 2 * * * cd /path/to/homelab-infra && /usr/bin/cloudflare-ip-groups sync || echo "Cloudflare sync failed" | mail -s "IP Groups Sync Failed" admin@example.com +``` + +### Systemd Timer (Alternative) + +**Service**: `/etc/systemd/system/cloudflare-sync.service` +```ini +[Unit] +Description=Sync Cloudflare IP Groups +After=network.target + +[Service] +Type=oneshot +User=homelab +WorkingDirectory=/path/to/homelab-infra +Environment="CLOUDFLARE_API_TOKEN=your-token" +Environment="CLOUDFLARE_ACCOUNT_ID=your-account-id" +ExecStart=/usr/bin/cloudflare-ip-groups sync +``` + +**Timer**: `/etc/systemd/system/cloudflare-sync.timer` +```ini +[Unit] +Description=Sync Cloudflare IP Groups Hourly + +[Timer] +OnCalendar=hourly +Persistent=true + +[Install] +WantedBy=timers.target +``` + +**Enable**: +```bash +sudo systemctl enable cloudflare-sync.timer +sudo systemctl start cloudflare-sync.timer +``` + +--- + +## Migration Steps to homelab-infra + +### 1. Copy Package Files + +```bash +# In homelab-infra repository +mkdir -p packages/cloudflare-api +cp -r /home/byron/python-libs/packages/cloudflare-api/* packages/cloudflare-api/ +``` + +### 2. Copy Example Configuration + +```bash +cp /home/byron/python-libs/ip_groups.example.yaml ip_groups.example.yaml +``` + +### 3. Update pyproject.toml (Root) + +Add to workspace members: +```toml +[tool.uv.workspace] +members = [ + "packages/cloudflare-api", + # ... other packages +] + +[tool.uv.sources] +byronwilliamscpa-cloudflare-api = { workspace = true } +``` + +### 4. Install Package + +```bash +# In homelab-infra +uv sync --all-extras +``` + +### 5. Configure Environment + +```bash +# Create .env file in homelab-infra +cat >> .env <=4.0.0` SDK instead of raw API calls +- Handles authentication, rate limiting, retries automatically +- Type-safe with SDK models + +### 2. Configuration-Driven +- YAML configuration for IP groups (not hardcoded) +- Easy to add/remove groups and sources +- Version controlled alongside infrastructure + +### 3. Source Abstraction +- Fetcher pattern for pluggable IP sources +- Easy to add new sources (Azure, custom APIs) +- Consistent interface for all sources + +### 4. Caching Strategy +- Caches fetched IPs to reduce API calls +- Invalidates on config change or TTL expiration +- Respects rate limits of external APIs + +### 5. Error Handling +- Custom exception hierarchy +- Converts SDK exceptions to domain exceptions +- Provides context in error messages + +### 6. Idempotent Syncing +- `sync_ip_list()` replaces all items atomically +- Handles concurrent bulk operations +- Returns detailed diff (added, removed, unchanged) + +### 7. Testing Strategy +- Mocked HTTP responses (no live API calls) +- Tests all fetchers with realistic data +- Tests error conditions and edge cases + +--- + +## Known Limitations + +1. **Azure Support**: Azure fetcher defined but not tested (Azure IP ranges URL is a download link, not direct JSON) +2. **Bulk Operations**: Cloudflare API has pending operation limits (handled with error) +3. **Large Lists**: Very large IP lists (>10,000 items) may hit API limits +4. **JSONPath**: Simple JSONPath implementation (supports `key[*].field`, not complex expressions) +5. **Rate Limiting**: No retry logic for rate limits (SDK handles it, but could be enhanced) + +--- + +## Recommended Next Steps + +### For homelab-infra Integration + +1. **Migrate Package** (see Migration Steps above) +2. **Configure IP Groups** for homelab environment: + - Home/office network IPs + - GitHub Actions for CI/CD + - Cloud provider IPs (GCP, AWS) +3. **Setup Cron Job** for hourly syncing +4. **Create Cloudflare Access Policies** that reference the synced IP lists +5. **Monitor Sync Logs** for failures + +### Potential Enhancements + +1. **GitHub Action Workflow** - Automate syncing on schedule or PR merge +2. **Alerting** - Notify on sync failures (email, Slack, PagerDuty) +3. **Metrics** - Track sync duration, IP count changes over time +4. **Webhooks** - Trigger sync when infrastructure changes +5. **Backup/Restore** - Export current Cloudflare lists before sync +6. **Diff Reports** - Email detailed diff of IP changes +7. **Azure Support** - Implement full Azure IP fetcher +8. **Web UI** - Dashboard for viewing groups, previewing changes + +--- + +## Support & Questions + +**Original Implementation**: Built by Claude Code in `python-libs` repository +**Branch**: `feat/assured-oss-artifact-registry` +**Tests**: 83 tests, all passing +**Documentation**: README.md in package directory + +**Contact**: Byron Williams + +--- + +## Appendix: Example IP Groups Configuration + +See [ip_groups.example.yaml](file:///home/byron/python-libs/ip_groups.example.yaml) for complete example. + +**Minimal Configuration**: +```yaml +version: "1.0" +cache_ttl_seconds: 3600 + +groups: + - name: home-network + cloudflare_list_name: home-ips + enabled: true + sources: + - type: static + ips: + - "203.0.113.50" # Your home IP + - "198.51.100.0/24" # Your office network + + - name: github-actions + cloudflare_list_name: github-ips + enabled: true + sources: + - type: github + services: [actions] + ip_version: 4 +``` + +**Advanced Configuration** (Multi-Source Group): +```yaml + - name: ci-cd + cloudflare_list_name: ci-cd-ips + description: "All CI/CD service IPs" + enabled: true + sources: + # GitHub Actions + - type: github + services: [actions] + + # Google Cloud Build regions + - type: google_cloud + regions: [us-central1, us-east1] + ip_version: 4 + + # Custom CI service + - type: url + url: "https://ci.example.com/ips.txt" +``` + +--- + +**END OF HANDOFF DOCUMENT** diff --git a/docs/planning/adr/README.md b/docs/planning/adr/README.md index a98652e..ba6847c 100644 --- a/docs/planning/adr/README.md +++ b/docs/planning/adr/README.md @@ -27,7 +27,9 @@ ADRs document significant architectural decisions along with their context and c | ADR | Title | Status | Date | |-----|-------|--------|------| -| *No ADRs yet* | Generate with `/plan` command | - | - | +| [ADR-001](./adr-001-monorepo-architecture.md) | UV Workspace Monorepo Architecture | Accepted | 2025-12-04 | +| [ADR-002](./adr-002-framework-agnostic-design.md) | Framework-Agnostic Core with Optional Adapters | Accepted | 2025-12-04 | +| [ADR-003](./adr-003-distribution-strategy.md) | Private Package Distribution Strategy | Accepted | 2025-12-04 | ## Creating ADRs diff --git a/docs/planning/adr/adr-001-monorepo-architecture.md b/docs/planning/adr/adr-001-monorepo-architecture.md new file mode 100644 index 0000000..4ad78e8 --- /dev/null +++ b/docs/planning/adr/adr-001-monorepo-architecture.md @@ -0,0 +1,147 @@ +--- +title: "ADR-001: UV Workspace Monorepo Architecture" +schema_type: planning +status: accepted +owner: core-maintainer +purpose: "Document the decision to use UV workspace monorepo for shared libraries." +tags: + - planning + - architecture + - decisions +--- + +## ADR-001: UV Workspace Monorepo Architecture + +> **Status**: Accepted +> **Date**: 2025-12-04 + +### TL;DR + +Use UV workspace monorepo to manage multiple independent packages (gcs-utilities, cloudflare-auth, etc.) in a single repository, enabling coordinated development while maintaining separate versioning and publishing. + +### Context + +#### Problem + +The organization needs to share Python utilities across multiple projects (data_ingestor, image-preprocessing-detector, ledgerbase, magg). Options for structuring shared code include: + +1. Single monolithic package with all utilities +2. Separate repositories for each utility package +3. Monorepo with independent packages + +#### Constraints + +- **Technical**: Need independent versioning per package; some packages have different dependencies +- **Business**: Single developer maintaining multiple projects; minimize context-switching overhead + +#### Significance + +This decision affects how packages are developed, versioned, tested, and published. Wrong choice leads to either tight coupling (monolithic) or coordination overhead (multiple repos). + +### Decision + +**We will use UV workspace monorepo because it provides independent package management with unified development experience.** + +#### Rationale + +- UV workspaces allow each package to have its own `pyproject.toml`, dependencies, and version +- Single `uv sync` installs all packages in development mode +- Cross-package dependencies resolved automatically within workspace +- Shared CI/CD, linting, and documentation infrastructure +- Already successfully using this pattern (current repo structure) + +### Options Considered + +#### Option 1: UV Workspace Monorepo βœ“ + +**Pros**: +- βœ… Single repository to manage +- βœ… Unified CI/CD pipeline +- βœ… Cross-package changes in single PR +- βœ… Shared dev dependencies and tooling +- βœ… Independent versioning per package + +**Cons**: +- ❌ Larger repository size over time +- ❌ Need discipline to maintain package boundaries + +#### Option 2: Separate Repositories + +**Pros**: +- βœ… Complete isolation between packages +- βœ… Independent release cycles + +**Cons**: +- ❌ Coordination overhead for cross-package changes +- ❌ Duplicated CI/CD, linting, documentation setup +- ❌ Version compatibility issues between packages + +#### Option 3: Single Monolithic Package + +**Pros**: +- βœ… Simplest structure +- βœ… Single version to track + +**Cons**: +- ❌ All-or-nothing dependency: projects must install everything +- ❌ Tight coupling makes testing harder +- ❌ Single failure can break entire package + +### Consequences + +#### Positive + +- βœ… **Reduced overhead**: Single PR for cross-package changes +- βœ… **Consistent tooling**: Shared Ruff, BasedPyright, pytest configuration +- βœ… **Easier onboarding**: One repository to clone for all shared utilities + +#### Trade-offs + +- ⚠️ **Package discipline required**: Must maintain clear boundaries between packages +- ⚠️ **CI complexity**: Need to detect which packages changed for selective testing + +#### Technical Debt + +- Consider package-specific CI triggers if test times grow significantly + +### Implementation + +#### Components Affected + +1. **Root pyproject.toml**: Workspace configuration with `[tool.uv.workspace]` +2. **packages/*/pyproject.toml**: Individual package configurations +3. **CI/CD**: Single workflow testing all packages + +#### Current Structure + +``` +python-libs/ +β”œβ”€β”€ pyproject.toml # Workspace root +β”œβ”€β”€ packages/ +β”‚ β”œβ”€β”€ cloudflare-auth/ +β”‚ β”‚ β”œβ”€β”€ pyproject.toml # Independent package +β”‚ β”‚ └── src/cloudflare_auth/ +β”‚ └── gcs-utilities/ +β”‚ β”œβ”€β”€ pyproject.toml # Independent package +β”‚ └── src/gcs_utilities/ +└── src/python_libs/ # Shared workspace utilities +``` + +### Validation + +#### Success Criteria + +- [x] Multiple packages coexist in single repository +- [x] Each package independently versionable +- [x] `uv sync` resolves all workspace dependencies +- [ ] CI/CD correctly tests affected packages only (future enhancement) + +#### Review Schedule + +- Initial: Complete (current architecture) +- Ongoing: Review if adding >5 packages + +### Related + +- [Tech Spec](../tech-spec.md#architecture): Package structure details +- [ADR-002](./adr-002-framework-agnostic-design.md): Framework coupling strategy diff --git a/docs/planning/adr/adr-002-framework-agnostic-design.md b/docs/planning/adr/adr-002-framework-agnostic-design.md new file mode 100644 index 0000000..9f89be8 --- /dev/null +++ b/docs/planning/adr/adr-002-framework-agnostic-design.md @@ -0,0 +1,171 @@ +--- +title: "ADR-002: Framework-Agnostic Core with Optional Adapters" +schema_type: planning +status: accepted +owner: core-maintainer +purpose: "Document the decision to separate framework-agnostic core from framework-specific adapters." +tags: + - planning + - architecture + - decisions +--- + +## ADR-002: Framework-Agnostic Core with Optional Adapters + +> **Status**: Accepted +> **Date**: 2025-12-04 + +### TL;DR + +Structure packages with framework-agnostic core logic and optional framework-specific adapters (e.g., FastAPI), enabling reuse across different contexts while providing convenient integrations. + +### Context + +#### Problem + +The `cloudflare-auth` package currently requires FastAPI/Starlette as dependencies. This tight coupling prevents use in: + +- CLI tools that validate tokens offline +- Background workers processing authenticated requests +- Future projects using different frameworks (Flask, Litestar) +- Non-web contexts needing auth logic + +#### Constraints + +- **Technical**: Cannot break existing FastAPI integrations +- **Business**: Limited time for major refactoring; must be incremental + +#### Significance + +Coupling to FastAPI limits reusability. If we add Flask support later, we'd either duplicate core logic or force a breaking refactor. Better to establish the pattern now. + +### Decision + +**We will separate framework-agnostic core from framework adapters because it maximizes reusability without sacrificing convenience.** + +#### Rationale + +- Core logic (JWT validation, user models, token parsing) has no framework dependency +- Framework-specific code (middleware, request/response handling) is isolated +- Optional dependencies allow consumers to install only what they need +- Pattern is widely used (e.g., SQLAlchemy core vs ORM, Pydantic vs FastAPI) + +### Options Considered + +#### Option 1: Framework-Agnostic Core + Optional Adapters βœ“ + +**Pros**: +- βœ… Core logic usable anywhere (CLI, workers, any framework) +- βœ… Optional dependencies keep install size small +- βœ… Easy to add new framework support +- βœ… Better testability (core logic tested without mocking framework) + +**Cons**: +- ❌ Slightly more complex package structure +- ❌ Refactoring effort for existing code + +#### Option 2: Maintain FastAPI Coupling + +**Pros**: +- βœ… No refactoring needed +- βœ… Simpler package structure + +**Cons**: +- ❌ Cannot use in non-FastAPI contexts +- ❌ Forces FastAPI dependency on all consumers +- ❌ Harder to test core logic in isolation + +#### Option 3: Separate Packages per Framework + +**Pros**: +- βœ… Complete isolation + +**Cons**: +- ❌ Duplicated core logic or complex dependency chain +- ❌ Multiple packages to version and maintain +- ❌ Overkill for current needs + +### Consequences + +#### Positive + +- βœ… **Broader applicability**: Auth logic usable in CLI tools, workers, any framework +- βœ… **Better testing**: Core logic tested without framework mocks +- βœ… **Future-proof**: Easy to add Flask, Litestar adapters if needed + +#### Trade-offs + +- ⚠️ **Refactoring required**: `cloudflare-auth` needs restructuring +- ⚠️ **Learning curve**: Developers must understand core vs adapter distinction + +#### Technical Debt + +- Current `cloudflare-auth` middleware.py mixes core and FastAPI code; needs refactoring + +### Implementation + +#### Components Affected + +1. **cloudflare-auth/core/**: Framework-agnostic JWT validation, models +2. **cloudflare-auth/fastapi/**: Middleware, dependencies, request handling +3. **pyproject.toml**: Optional dependencies for framework extras + +#### Target Structure + +``` +cloudflare-auth/ +β”œβ”€β”€ src/cloudflare_auth/ +β”‚ β”œβ”€β”€ __init__.py # Public API +β”‚ β”œβ”€β”€ core/ # Framework-agnostic +β”‚ β”‚ β”œβ”€β”€ __init__.py +β”‚ β”‚ β”œβ”€β”€ models.py # CloudflareUser, CloudflareJWTClaims +β”‚ β”‚ β”œβ”€β”€ validators.py # JWT validation logic +β”‚ β”‚ └── exceptions.py # Auth exceptions +β”‚ └── fastapi/ # FastAPI-specific +β”‚ β”œβ”€β”€ __init__.py +β”‚ β”œβ”€β”€ middleware.py # CloudflareAuthMiddleware +β”‚ └── dependencies.py # get_current_user, require_admin +└── pyproject.toml +``` + +#### Optional Dependencies + +```toml +[project.optional-dependencies] +fastapi = ["fastapi>=0.100.0", "starlette>=0.27.0"] +redis = ["redis>=4.0.0"] +all = ["fastapi>=0.100.0", "starlette>=0.27.0", "redis>=4.0.0"] +``` + +#### Usage Examples + +```python +# Core only (CLI, workers) +from cloudflare_auth.core import CloudflareJWTValidator, CloudflareUser +validator = CloudflareJWTValidator(team_domain="example", policy_aud="...") +claims = validator.validate_token(jwt_token) + +# With FastAPI +from cloudflare_auth.fastapi import CloudflareAuthMiddleware, get_current_user +app.add_middleware(CloudflareAuthMiddleware) +``` + +### Validation + +#### Success Criteria + +- [ ] Core validation works without FastAPI installed +- [ ] FastAPI middleware works with `[fastapi]` extra +- [ ] Existing FastAPI code migrates with minimal changes +- [ ] Test coverage maintained at 80%+ + +#### Review Schedule + +- Initial: After Phase 2 refactoring +- Ongoing: When adding new framework adapters + +### Related + +- [ADR-001](./adr-001-monorepo-architecture.md): Monorepo structure +- [ADR-003](./adr-003-distribution-strategy.md): Package distribution approach +- [Tech Spec](../tech-spec.md#security): Authentication details diff --git a/docs/planning/adr/adr-003-distribution-strategy.md b/docs/planning/adr/adr-003-distribution-strategy.md new file mode 100644 index 0000000..972fd1a --- /dev/null +++ b/docs/planning/adr/adr-003-distribution-strategy.md @@ -0,0 +1,181 @@ +--- +title: "ADR-003: Private Package Distribution Strategy" +schema_type: planning +status: accepted +owner: core-maintainer +purpose: "Document the decision for private package distribution via Git dependencies evolving to Artifact Registry." +tags: + - planning + - architecture + - decisions + - distribution +--- + +## ADR-003: Private Package Distribution Strategy + +> **Status**: Accepted +> **Date**: 2025-12-04 + +### TL;DR + +Start with Git dependencies for immediate usability, then migrate to Google Artifact Registry for better caching and integration with Assured OSS infrastructure. + +### Context + +#### Problem + +python-libs packages need to be installable by other organization projects without publishing to public PyPI. Options include: + +1. Git dependencies (install directly from GitHub) +2. GitHub Packages (GitHub's package registry) +3. Google Artifact Registry (GCP-native Python repository) +4. Third-party private PyPI (Gemfury, Packagr, etc.) + +#### Constraints + +- **Technical**: Must work with UV package manager; need authentication for CI/CD +- **Business**: No public distribution; prefer GCP integration (already using GCS, Assured OSS) + +#### Significance + +Distribution strategy affects install times, caching, security scanning integration, and developer experience. Wrong choice creates friction for package consumers. + +### Decision + +**We will use Git dependencies initially, migrating to Artifact Registry when caching benefits justify setup complexity.** + +#### Rationale + +- Git dependencies work immediately with zero infrastructure +- UV has excellent Git dependency support with commit pinning +- Artifact Registry provides proper package caching and Assured OSS integration +- Two-phase approach balances immediate usability with long-term optimization + +### Options Considered + +#### Option 1: Git Dependencies β†’ Artifact Registry βœ“ + +**Pros**: +- βœ… Immediate availability (Git works now) +- βœ… No infrastructure setup required initially +- βœ… UV supports Git dependencies natively +- βœ… Artifact Registry integrates with existing GCP infrastructure +- βœ… Assured OSS scanning available with Artifact Registry + +**Cons**: +- ❌ Git dependencies slower (no package caching) +- ❌ Migration effort when moving to Artifact Registry + +#### Option 2: GitHub Packages Only + +**Pros**: +- βœ… Integrated with GitHub (no separate service) + +**Cons**: +- ❌ Python support is limited (no native pip/UV integration) +- ❌ Requires PAT for authentication +- ❌ No Assured OSS integration + +#### Option 3: Third-Party Private PyPI + +**Pros**: +- βœ… Drop-in replacement for public PyPI + +**Cons**: +- ❌ Additional service to manage and pay for +- ❌ Another credential to manage +- ❌ No GCP integration + +#### Option 4: Artifact Registry Only (Skip Git) + +**Pros**: +- βœ… Full caching from day one + +**Cons**: +- ❌ Significant upfront setup +- ❌ Delays usability until infrastructure ready + +### Consequences + +#### Positive + +- βœ… **Immediate usability**: Projects can use packages today via Git +- βœ… **GCP integration**: Artifact Registry leverages existing infrastructure +- βœ… **Security scanning**: Assured OSS integration when on Artifact Registry + +#### Trade-offs + +- ⚠️ **Initial performance**: Git dependencies slower than cached packages +- ⚠️ **Migration work**: Need to update consumer projects when switching + +#### Technical Debt + +- Document migration path from Git to Artifact Registry +- Consumer projects will need dependency updates during migration + +### Implementation + +#### Phase 1: Git Dependencies (Immediate) + +Consumer projects add dependencies like: + +```toml +# pyproject.toml +[project] +dependencies = [ + "byronwilliamscpa-gcs-utilities @ git+https://github.com/ByronWilliamsCPA/python-libs.git@v0.1.0#subdirectory=packages/gcs-utilities", +] + +# Or using UV sources for development +[tool.uv.sources] +byronwilliamscpa-gcs-utilities = { git = "https://github.com/ByronWilliamsCPA/python-libs.git", subdirectory = "packages/gcs-utilities", tag = "gcs-utilities-v0.1.0" } +``` + +#### Phase 2: Artifact Registry (Future) + +1. Create Python repository in Artifact Registry +2. Configure GitHub Actions to publish on release +3. Update consumer projects to use Artifact Registry URL + +```toml +# Future: Artifact Registry +[[tool.uv.index]] +name = "byronwilliamscpa" +url = "https://us-python.pkg.dev/PROJECT_ID/python-libs/simple/" + +[project] +dependencies = [ + "byronwilliamscpa-gcs-utilities>=0.1.0", +] +``` + +#### Components Affected + +1. **Consumer pyproject.toml**: Dependency declarations +2. **GitHub Actions**: Publishing workflow (Phase 2) +3. **GCP Infrastructure**: Artifact Registry repository (Phase 2) + +### Validation + +#### Success Criteria + +**Phase 1 (Git)**: +- [ ] Consumer projects can install via Git URL +- [ ] CI/CD pipelines work with Git dependencies +- [ ] Version pinning works correctly + +**Phase 2 (Artifact Registry)**: +- [ ] Packages published to Artifact Registry on release +- [ ] Consumer projects install from Artifact Registry +- [ ] Install times improved vs Git dependencies + +#### Review Schedule + +- Initial: After 3 consumer projects using Git dependencies +- Migration trigger: When CI install times exceed 2 minutes + +### Related + +- [ADR-001](./adr-001-monorepo-architecture.md): Monorepo structure +- [Tech Spec](../tech-spec.md#infrastructure): CI/CD details +- [Roadmap Phase 3](../roadmap.md#phase-3): Distribution milestone diff --git a/docs/planning/project-vision.md b/docs/planning/project-vision.md index 3660225..ef40816 100644 --- a/docs/planning/project-vision.md +++ b/docs/planning/project-vision.md @@ -1,7 +1,7 @@ --- title: "Python Libs - Project Vision & Scope" schema_type: planning -status: draft +status: active owner: core-maintainer purpose: "Document the project vision, scope, and success criteria." tags: @@ -11,38 +11,102 @@ component: Strategy source: "/plan command generation" --- -> **Status**: Awaiting Generation +## Project Vision & Scope: Python Libs -This document will be generated by Claude Code based on your project description. +> **Status**: Active | **Version**: 1.0 | **Updated**: 2025-12-04 -## How to Generate +## TL;DR -1. Open Claude Code in this project directory -2. Describe your project concept -3. Run: `/plan ` +Python Libs is a UV workspace monorepo providing shared, reusable Python utilities for ByronWilliamsCPA projects. It consolidates common patterns (GCS operations, authentication, logging, configuration) to eliminate duplication across repositories and establish organizational standards. -Or manually: +## Problem Statement -```text -Generate the Project Vision & Scope document for this project. -Use the template in .claude/skills/project-planning/templates/pvs-template.md -Write output to docs/planning/project-vision.md +### Pain Point -Project concept: [describe your project here] -```text +Multiple projects (data_ingestor, image-preprocessing-detector, ledgerbase, magg, PromptCraft) duplicate identical utilities: GCS authentication patterns, structured logging setup, Pydantic configuration management, and error handling. Each project independently maintains these utilities, leading to: -## What This Document Contains +- Inconsistent implementations across projects +- Duplicated bug fixes and security patches +- Increased maintenance burden +- Diverging patterns that complicate developer onboarding -Once generated, this document will include: +### Target Users -- **TL;DR**: 2-3 sentence summary -- **Problem Statement**: What problem we're solving -- **Target Users**: Who will use this -- **Solution Overview**: Core capabilities -- **Scope Definition**: What's in/out of MVP -- **Constraints**: Technical and business limitations -- **Success Metrics**: How we measure success +- **Primary**: ByronWilliamsCPA developers working on Python projects +- **Context**: When starting new projects or needing cross-cutting concerns (auth, storage, logging) -## Template Reference +### Success Metrics -See `.claude/skills/project-planning/templates/pvs-template.md` for the full template structure. +- **Code Duplication**: Reduce duplicated utility code by 70% across active projects +- **Onboarding Time**: New project setup from template to first commit < 30 minutes +- **Adoption**: 100% of new Python projects use python-libs packages + +## Solution Overview + +### Core Value + +A centralized, well-tested library of shared utilities that any Python project can import, reducing boilerplate and ensuring consistent patterns across the organization. + +### Key Capabilities (Current + MVP) + +1. **GCS Utilities**: Streamlined Google Cloud Storage operations with base64 credential handling +2. **Cloudflare Auth**: Zero Trust authentication middleware for FastAPI applications +3. **Structured Logging**: Consistent structlog+rich logging setup across all projects +4. **Configuration Management**: Pydantic Settings patterns with environment validation + +## Scope Definition + +### In Scope (Phase 1 - Foundation) + +- βœ… **gcs-utilities**: Complete GCS client wrapper - *Already implemented* +- βœ… **cloudflare-auth**: Cloudflare Access JWT validation - *Already implemented* +- βœ… **Core logging utilities**: Unified structlog configuration +- βœ… **Core exceptions**: Shared exception hierarchy +- βœ… **Configuration patterns**: Base Pydantic Settings classes + +### In Scope (Phase 2 - Consolidation) + +- βœ… **Framework adapters**: Separate FastAPI-specific code from core logic +- βœ… **Financial utilities**: Decimal precision utilities from ledgerbase patterns +- βœ… **Schema base classes**: Reusable Pydantic models for cross-project validation + +### In Scope (Phase 3 - Distribution) + +- βœ… **Artifact Registry publishing**: Private package distribution +- βœ… **Cookiecutter integration**: Update template to use python-libs packages +- βœ… **Migration guides**: Help existing projects adopt shared packages + +### Out of Scope + +- ❌ **Domain-specific logic**: Business logic stays in individual projects +- ❌ **ML/AI model code**: Stays in RAG pipeline projects +- ❌ **Database migrations**: Project-specific, not shared +- πŸ”„ **Device detection utilities**: Deferred pending RAG pipeline stabilization + +## Constraints + +### Technical + +- **Platform**: Python library packages (installable via UV/pip) +- **Language**: Python 3.10-3.14 (matching cookiecutter template) +- **Architecture**: UV workspace monorepo with independent packages +- **Framework Strategy**: Core logic framework-agnostic, with optional FastAPI adapters + +### Business + +- **Resources**: Single developer, async development +- **Compatibility**: Must work with existing projects without breaking changes +- **Distribution**: Private packages only (no public PyPI) + +## Assumptions to Validate + +- [ ] Git dependencies provide acceptable install performance for CI/CD +- [ ] Artifact Registry setup complexity is justified by caching benefits +- [ ] Framework-agnostic refactoring of cloudflare-auth is feasible without major rewrites +- [ ] Existing projects can migrate incrementally without disruption + +## Related Documents + +- [Architecture Decisions](./adr/) +- [Technical Spec](./tech-spec.md) +- [Roadmap](./roadmap.md) diff --git a/docs/planning/roadmap.md b/docs/planning/roadmap.md index 4ce0f8e..6ddba86 100644 --- a/docs/planning/roadmap.md +++ b/docs/planning/roadmap.md @@ -1,7 +1,7 @@ --- title: "Python Libs - Development Roadmap" schema_type: planning -status: draft +status: active owner: core-maintainer purpose: "Document the phased implementation plan and milestones." tags: @@ -11,39 +11,259 @@ component: Strategy source: "/plan command generation" --- -> **Status**: Awaiting Generation +## Development Roadmap: Python Libs -This document will be generated by Claude Code based on your project description. +> **Status**: Active | **Updated**: 2025-12-04 -## How to Generate +### TL;DR -1. Open Claude Code in this project directory -2. Describe your project concept -3. Run: `/plan ` +Consolidate shared utilities into python-libs across 4 phases: Foundation (complete existing packages), Stabilization (tests, docs, first consumers), Consolidation (refactor for reusability), Expansion (add new packages from pattern analysis), Distribution (Artifact Registry + cookiecutter integration). -Or manually: +## Timeline Overview ```text -Generate the Development Roadmap for this project. -Use the template in .claude/skills/project-planning/templates/roadmap-template.md -Write output to docs/planning/roadmap.md +Phase 0: Foundation β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ Complete - Existing packages working +Phase 1: Stabilization β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘ Planned - Tests, docs, first consumers +Phase 2: Consolidation β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘ Planned - Framework-agnostic refactor +Phase 3: Expansion β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘ Planned - New packages from analysis +Phase 4: Distribution β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘ Planned - Artifact Registry, template +``` -Project concept: [describe your project here] -```text +## Milestones + +| Milestone | Target | Status | Dependencies | +|-----------|--------|--------|--------------| +| M0: Workspace Setup | Complete | βœ… Done | None | +| M1: First Consumer | Phase 1 | ⏸️ Planned | M0 | +| M2: Framework-Agnostic Auth | Phase 2 | ⏸️ Planned | M1 | +| M3: Logging Package | Phase 3 | ⏸️ Planned | M1 | +| M4: Artifact Registry | Phase 4 | ⏸️ Planned | M1, M2 | + +--- + +## Phase 0: Foundation (Complete) + +### Objective + +Establish UV workspace monorepo with existing packages migrated. + +### Deliverables + +- [x] UV workspace configured with `packages/*` members +- [x] gcs-utilities package migrated +- [x] cloudflare-auth package migrated +- [x] Shared workspace utilities in `src/python_libs/` +- [x] CI/CD pipeline configured + +### Success Criteria + +- βœ… `uv sync` installs all packages +- βœ… Tests pass for both packages +- βœ… Pre-commit hooks working + +--- + +## Phase 1: Stabilization + +### Objective + +Prepare packages for external consumption with comprehensive tests, documentation, and first consumer migration. + +### Deliverables + +- [ ] Test coverage β‰₯ 80% for both packages +- [ ] API documentation generated +- [ ] README with usage examples for each package +- [ ] First consumer project using Git dependencies + +### Success Criteria + +- βœ… All tests passing with 80%+ coverage +- βœ… Consumer project installs and uses package successfully +- βœ… No breaking changes to existing package APIs + +### User Stories + +#### US-101: Package Documentation + +**As a** developer consuming these packages +**I want** clear documentation with examples +**So that** I can integrate quickly without reading source code + +**Acceptance Criteria**: + +- [ ] Each package has README with installation and basic usage +- [ ] API reference generated from docstrings +- [ ] Example code tested and working + +#### US-102: First Consumer Migration + +Migrate one project to validate distribution approach works with Git dependencies. + +### Dependencies + +- Requires: Phase 0 complete +- Blocks: Phase 2, Phase 3, Phase 4 + +--- + +## Phase 2: Consolidation + +### Objective + +Refactor cloudflare-auth for framework-agnostic core per [ADR-002](./adr/adr-002-framework-agnostic-design.md). + +### Deliverables + +- [ ] cloudflare-auth/core/ with framework-agnostic logic +- [ ] cloudflare-auth/fastapi/ with FastAPI-specific code +- [ ] Optional dependencies configured (`[fastapi]`, `[redis]`) +- [ ] Migration guide for existing consumers + +### Success Criteria + +- βœ… Core validation works without FastAPI installed +- βœ… FastAPI middleware works with `[fastapi]` extra +- βœ… Existing consumers work after updating imports +- βœ… Test coverage maintained at 80%+ + +### User Stories + +#### US-201: Framework-Agnostic Core + +**As a** CLI tool developer +**I want** to validate Cloudflare tokens without FastAPI +**So that** I can authenticate users in non-web contexts + +**Acceptance Criteria**: + +- [ ] `from cloudflare_auth.core import CloudflareJWTValidator` works +- [ ] Validation succeeds/fails correctly with test tokens +- [ ] No FastAPI imports in core module + +### Dependencies + +- Requires: Phase 1 complete (tests, docs baseline) +- Blocks: Phase 4 (Artifact Registry) + +--- + +## Phase 3: Expansion + +### Objective + +Add new packages based on pattern analysis from organizational repositories. + +### Deliverables + +- [ ] `python-libs-logging`: Unified structlog configuration +- [ ] Enhanced `python_libs.core.config`: Base Pydantic Settings +- [ ] Enhanced `python_libs.utils.financial`: Decimal precision utilities + +### Candidate Packages (Prioritized) + +| Package | Source Pattern | Priority | Rationale | +|---------|----------------|----------|-----------| +| Logging utilities | image-preprocessing-detector, data_ingestor | High | Used in every project | +| Config management | Multiple projects | High | Reduces boilerplate | +| Financial utilities | ledgerbase | Medium | Specialized but valuable | +| Schema base classes | RAG projects | Medium | Cross-project validation | +| Device detection | image-preprocessing-detector | Low | RAG-specific, defer | + +### Success Criteria + +- βœ… Each new package has tests and documentation +- βœ… At least 2 consumer projects adopt each package +- βœ… Reduces code duplication measurably + +### User Stories + +#### US-301: Logging Package + +**As a** developer starting a new project +**I want** consistent structured logging setup +**So that** I don't reinvent logging configuration + +**Acceptance Criteria**: + +- [ ] Single function call configures structlog + rich +- [ ] JSON output mode for production +- [ ] Rich console output for development +- [ ] Correlation ID support + +### Dependencies + +- Requires: Phase 1 complete +- Can run in parallel with Phase 2 + +--- + +## Phase 4: Distribution + +### Objective + +Establish mature distribution via Artifact Registry and integrate with cookiecutter template. + +### Deliverables + +- [ ] Artifact Registry Python repository configured +- [ ] GitHub Actions workflow for publishing +- [ ] Consumer projects migrated from Git to Artifact Registry +- [ ] Cookiecutter template updated to use python-libs + +### Success Criteria + +- βœ… Packages published to Artifact Registry on release +- βœ… Install time < 10s (vs ~30s for Git) +- βœ… New projects from template use python-libs by default + +### User Stories + +#### US-401: Artifact Registry Publishing + +**As a** package maintainer +**I want** automatic publishing on release +**So that** consumers get updates without manual intervention + +**Acceptance Criteria**: + +- [ ] GitHub Action publishes on tag push +- [ ] Version matches Git tag +- [ ] Package installable from Artifact Registry URL + +#### US-402: Cookiecutter Integration + +Update template to include python-libs as optional dependency with package selection prompts. + +### Dependencies + +- Requires: Phase 1, Phase 2 complete +- Cookiecutter integration can start after Phase 1 + +--- + +## Risk Register + +| Risk | Probability | Impact | Mitigation | +|------|-------------|--------|------------| +| Git dependency performance unacceptable | Medium | Medium | Prioritize Artifact Registry migration | +| Framework-agnostic refactor breaks consumers | Low | High | Deprecation warnings, migration guide | +| Artifact Registry setup complexity | Medium | Low | Detailed documentation, start with Git | +| Low adoption of new packages | Medium | Medium | Migrate own projects first as proof | -## What This Document Contains +## Definition of Done -Once generated, this document will include: +A feature/package is complete when: -- **Timeline Overview**: Visual phase timeline -- **Milestones**: Key deliverables with dates -- **Phase 0: Foundation**: Dev environment, CI/CD setup -- **Phase 1: MVP Core**: Essential features with user stories -- **Phase 2: Enhancement**: Additional features -- **Phase 3: Polish**: Testing, documentation, release -- **Risk Register**: Identified risks and mitigations -- **Definition of Done**: Completion criteria +- [ ] Code reviewed and approved +- [ ] Tests written and passing (80%+ coverage) +- [ ] Documentation updated (README, API docs) +- [ ] No linting errors (Ruff, BasedPyright) +- [ ] Changelog updated +- [ ] Merged to main -## Template Reference +## Related Documents -See `.claude/skills/project-planning/templates/roadmap-template.md` for the full template structure. +- [Project Vision](./project-vision.md) +- [Technical Spec](./tech-spec.md) +- [Architecture Decisions](./adr/) diff --git a/docs/planning/tech-spec.md b/docs/planning/tech-spec.md index 24460d6..501fb96 100644 --- a/docs/planning/tech-spec.md +++ b/docs/planning/tech-spec.md @@ -1,7 +1,7 @@ --- title: "Python Libs - Technical Specification" schema_type: planning -status: draft +status: active owner: core-maintainer purpose: "Document the technical architecture and implementation details." tags: @@ -11,48 +11,282 @@ component: Development-Tools source: "/plan command generation" --- -> **Status**: Awaiting Generation +## Technical Implementation Spec: Python Libs -This document will be generated by Claude Code based on your project description. +> **Status**: Active | **Version**: 1.0 | **Updated**: 2025-12-04 -## How to Generate +### TL;DR -1. Open Claude Code in this project directory -2. Describe your project concept -3. Run: `/plan ` +UV workspace monorepo hosting independent Python packages for shared utilities. Each package follows framework-agnostic core design with optional adapters. Distribution via Git dependencies initially, migrating to Artifact Registry. -Or manually: +## Technology Stack + +### Core + +- **Language**: Python 3.10-3.14 +- **Package Manager**: UV with workspace support +- **Build Backend**: Hatchling + +### Code Quality + +- **Linter/Formatter**: Ruff (88 chars, Google style) +- **Type Checker**: BasedPyright (strict mode) +- **Testing**: pytest with pytest-cov, pytest-asyncio +- **Security**: Bandit, pip-audit, Safety + +### Infrastructure + +- **CI/CD**: GitHub Actions +- **Documentation**: MkDocs Material +- **Version Control**: Git with conventional commits + +## Architecture + +### Pattern + +UV Workspace Monorepo - See [ADR-001](./adr/adr-001-monorepo-architecture.md) + +### Component Diagram ```text -Generate the Technical Implementation Specification for this project. -Use the template in .claude/skills/project-planning/templates/tech-spec-template.md -Write output to docs/planning/tech-spec.md +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ python-libs (workspace) β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ packages/ β”‚ +β”‚ β”œβ”€β”€ cloudflare-auth/ β”œβ”€β”€ gcs-utilities/ β”‚ +β”‚ β”‚ β”œβ”€β”€ core/ β”‚ β”œβ”€β”€ client.py β”‚ +β”‚ β”‚ β”‚ β”œβ”€β”€ models.py β”‚ β”œβ”€β”€ exceptions.py β”‚ +β”‚ β”‚ β”‚ β”œβ”€β”€ validators.py β”‚ └── __init__.py β”‚ +β”‚ β”‚ β”‚ └── exceptions.py β”‚ β”‚ +β”‚ β”‚ └── fastapi/ └── [future packages] β”‚ +β”‚ β”‚ β”œβ”€β”€ middleware.py β”‚ +β”‚ β”‚ └── dependencies.py β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ src/python_libs/ (shared workspace utilities) β”‚ +β”‚ β”œβ”€β”€ core/ β”‚ +β”‚ β”‚ β”œβ”€β”€ config.py # Base Pydantic Settings β”‚ +β”‚ β”‚ └── exceptions.py # Shared exception hierarchy β”‚ +β”‚ └── utils/ β”‚ +β”‚ β”œβ”€β”€ logging.py # Structured logging setup β”‚ +β”‚ └── financial.py # Decimal precision utilities β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Component Responsibilities + +| Component | Purpose | Key Functions | +|-----------|---------|---------------| +| `gcs-utilities` | Google Cloud Storage operations | upload, download, list, delete with auth handling | +| `cloudflare-auth` | Zero Trust authentication | JWT validation, middleware, user models | +| `python_libs.core` | Shared foundations | Base config, exceptions | +| `python_libs.utils` | Cross-cutting utilities | Logging setup, financial calculations | + +## Data Model + +### gcs-utilities (Existing) + +```python +from gcs_utilities import GCSClient + +client = GCSClient() # Reads GCP_SA_KEY from env +client.upload_file("local.txt", "remote/path.txt") +files = client.list_files(prefix="remote/") +``` + +**Core Entities**: + +```python +class GCSClient: + bucket_name: str | None + project_id: str | None + client: storage.Client + +class GCSAuthError(Exception): ... +class GCSUploadError(Exception): ... +class GCSDownloadError(Exception): ... +class GCSNotFoundError(Exception): ... +``` + +### cloudflare-auth (Existing, Refactoring Planned) + +```python +# Current (FastAPI-coupled) +from cloudflare_auth import setup_cloudflare_auth_enhanced, get_current_user + +# Future (Framework-agnostic core) +from cloudflare_auth.core import CloudflareJWTValidator +from cloudflare_auth.fastapi import CloudflareAuthMiddleware +``` + +**Core Entities**: + +```python +class CloudflareUser: + email: str + user_id: str + user_tier: UserTier + iat: datetime + exp: datetime + +class CloudflareJWTClaims: + email: str + sub: str + iat: int + exp: int + iss: str + aud: list[str] +``` + +### python_libs.core (Workspace Shared) + +```python +from python_libs.core.config import BaseSettings +from python_libs.core.exceptions import ValidationError, ConfigurationError +``` + +### python_libs.utils (Workspace Shared) + +```python +from python_libs.utils.logging import setup_logging, get_logger +from python_libs.utils.financial import round_currency, validate_amount +``` + +## Distribution + +### Git Dependencies (Phase 1) + +```toml +# Consumer pyproject.toml +[project] +dependencies = [ + "byronwilliamscpa-gcs-utilities @ git+https://github.com/ByronWilliamsCPA/python-libs.git@gcs-utilities-v0.1.0#subdirectory=packages/gcs-utilities", +] +``` + +### Artifact Registry (Phase 2) + +See [ADR-003](./adr/adr-003-distribution-strategy.md) for migration plan. + +## Security + +### Authentication Handling + +- **GCS**: Base64-encoded service account keys via `GCP_SA_KEY` environment variable +- **Cloudflare**: JWT validation against Cloudflare Access public keys +- **Credentials**: Never logged, temporary files cleaned up automatically + +### Input Validation + +- Path traversal prevention in GCS operations (`_sanitize_gcs_path`) +- JWT size limits to prevent DoS +- Email sanitization in logs + +### Data Protection + +- **At Rest**: Credentials in environment variables or secrets manager +- **In Transit**: All external calls over HTTPS/TLS +- **Sensitive Data**: Never log credentials, tokens, or PII + +## Error Handling + +### Strategy + +Fail-fast with descriptive exceptions. Each package defines its own exception hierarchy inheriting from a base exception. + +### Exception Hierarchy + +```python +# gcs-utilities +GCSError (base) +β”œβ”€β”€ GCSAuthError +β”œβ”€β”€ GCSConfigError +β”œβ”€β”€ GCSUploadError +β”œβ”€β”€ GCSDownloadError +└── GCSNotFoundError + +# cloudflare-auth +CloudflareAuthError (base) +β”œβ”€β”€ TokenValidationError +β”œβ”€β”€ TokenExpiredError +└── InvalidAudienceError + +# python_libs.core +PythonLibsError (base) +β”œβ”€β”€ ConfigurationError +β”œβ”€β”€ ValidationError +└── ExternalServiceError +``` + +### Logging + +- **Format**: Structured JSON via structlog +- **Levels**: DEBUG for operations, INFO for success, WARNING for recoverable issues, ERROR for failures +- **Sensitive**: Never log credentials, tokens, full file contents + +## Performance + +| Metric | Target | Measurement | +|--------|--------|-------------| +| Package install (Git) | < 30s | CI/CD timing | +| Package install (Artifact Registry) | < 10s | CI/CD timing | +| JWT validation | < 10ms | Unit test benchmark | +| GCS small file upload | < 2s | Integration test | + +## Testing + +### Coverage Target + +- Minimum: 80% +- Critical paths (auth, credentials): 100% + +### Test Types + +- **Unit**: Core logic, validators, models (no external calls) +- **Integration**: GCS operations with emulator, JWT with test tokens +- **Package Tests**: Each package has own test directory + +### Test Structure -Project concept: [describe your project here] ```text +packages/ +β”œβ”€β”€ gcs-utilities/ +β”‚ └── tests/ +β”‚ β”œβ”€β”€ test_client.py +β”‚ β”œβ”€β”€ test_exceptions.py +β”‚ └── conftest.py +β”œβ”€β”€ cloudflare-auth/ +β”‚ └── tests/ +β”‚ β”œβ”€β”€ test_validators.py +β”‚ β”œβ”€β”€ test_models.py +β”‚ └── conftest.py +└── tests/ # Workspace-level tests + β”œβ”€β”€ unit/ + └── integration/ +``` -## Pre-Filled Context +## Versioning -This project was created with: +### Semantic Versioning -- **Python**: 3.12 -- **Package Manager**: UV -- **Linter**: Ruff -- **Type Checker**: BasedPyright -- **Testing**: pytest +Each package versioned independently following SemVer: -## What This Document Contains +- **Major**: Breaking API changes +- **Minor**: New features, backward compatible +- **Patch**: Bug fixes, backward compatible + +### Git Tags + +```text +gcs-utilities-v0.1.0 +cloudflare-auth-v0.2.0 +``` -Once generated, this document will include: +### Changelog -- **Technology Stack**: Complete tech choices with versions -- **Architecture**: Component diagram and responsibilities -- **Data Model**: Entity definitions and relationships -- **API Specification**: Endpoints, request/response formats -- **Security**: Auth, authorization, data protection -- **Error Handling**: Strategy and error codes -- **Performance Requirements**: Targets and measurement +Each package maintains its own `CHANGELOG.md` following Keep a Changelog format. -## Template Reference +## Related Documents -See `.claude/skills/project-planning/templates/tech-spec-template.md` for the full template structure. +- [Project Vision](./project-vision.md) +- [Architecture Decisions](./adr/) +- [Development Roadmap](./roadmap.md) diff --git a/docs/secure.md b/docs/secure.md new file mode 100644 index 0000000..bb3b60a --- /dev/null +++ b/docs/secure.md @@ -0,0 +1,540 @@ +# python-libs Publishing Handoff Document + +> **Repository:** [ByronWilliamsCPA/python-libs](https://github.com/ByronWilliamsCPA/python-libs) +> **Last Updated:** 2025-12-04 +> **Status:** Ready for Implementation + +## Overview + +This document provides everything needed to set up publishing for the `python-libs` monorepo to GCP Artifact Registry, with secrets managed via Infisical. + +### What's Already Done βœ… + +| Component | Status | Details | +|-----------|--------|---------| +| Artifact Registry | βœ… Created | `us-central1-python.pkg.dev/assured-oss-457903/python-libs` | +| Service Account | βœ… Configured | `assured-oss-accessor@assured-oss-457903.iam.gserviceaccount.com` (has writer access) | +| Infisical Server | βœ… Running | https://secrets.byronwilliamscpa.com | +| Package Structure | βœ… Complete | `cloudflare-auth` and `gcs-utilities` packages ready | + +### What You Need to Do + +1. Configure Infisical project and secrets +2. Create machine identity for GitHub Actions +3. Add Infisical credentials to GitHub +4. Add the publishing workflow +5. Fix cloudflare-auth imports +6. Test publish with gcs-utilities +7. Publish cloudflare-auth + +--- + +## Part 1: Infisical Setup + +### 1.1 Create Project + +1. Go to https://secrets.byronwilliamscpa.com +2. Click **+ Add New Project** +3. Name: `python-libs` +4. Click **Create Project** + +### 1.2 Add GCP Secret + +1. Select **prod** environment +2. Click **+ Add Secret** +3. Configure: + - **Key:** `GCP_SA_KEY_BASE64` + - **Value:** (see command below) + +```bash +# Generate base64-encoded service account key +gcloud iam service-accounts keys create /tmp/sa-key.json \ + --iam-account=assured-oss-accessor@assured-oss-457903.iam.gserviceaccount.com + +# Copy this output as the secret value +cat /tmp/sa-key.json | base64 -w 0 + +# Clean up (important!) +rm /tmp/sa-key.json +``` + +### 1.3 Create Machine Identity + +1. Go to **Organization Settings** β†’ **Machine Identities** +2. Click **+ Create Identity** +3. Name: `github-actions-python-libs` +4. Click **Create** + +### 1.4 Add Universal Auth + +1. Select the machine identity you just created +2. Go to **Authentication** tab +3. Click **+ Add Authentication Method** +4. Select **Universal Auth** +5. Configure: + - **Access Token TTL:** `300` (5 minutes) + - **Max TTL:** `86400` (24 hours) + - **Access Token Max Uses:** `0` (unlimited) +6. Click **Create** +7. **⚠️ IMPORTANT: Copy and save the Client ID and Client Secret now!** + +### 1.5 Grant Project Access + +1. Go back to **Projects** β†’ **python-libs** +2. Click **Access Control** in sidebar +3. Click **+ Add Member** +4. Select **Machine Identity** tab +5. Choose `github-actions-python-libs` +6. Role: **Member** +7. Environments: Check **prod** +8. Click **Add** + +--- + +## Part 2: GitHub Configuration + +### 2.1 Add Repository Secrets + +1. Go to https://github.com/ByronWilliamsCPA/python-libs +2. Click **Settings** β†’ **Secrets and variables** β†’ **Actions** +3. Click **New repository secret** +4. Add these two secrets: + +| Name | Value | +|------|-------| +| `INFISICAL_CLIENT_ID` | (Client ID from step 1.4) | +| `INFISICAL_CLIENT_SECRET` | (Client Secret from step 1.4) | + +### 2.2 Add Publishing Workflow + +Create `.github/workflows/publish.yml` with this content: + +```yaml +# .github/workflows/publish.yml +# Publishes packages to GCP Artifact Registry when tags are pushed +# Secrets are fetched from Infisical + +name: Publish Package + +on: + push: + tags: + - 'cloudflare-auth-v*' + - 'gcs-utilities-v*' + +permissions: + contents: read + +env: + INFISICAL_DOMAIN: https://secrets.byronwilliamscpa.com + INFISICAL_PROJECT: python-libs + INFISICAL_ENV: prod + ARTIFACT_REGISTRY_URL: https://us-central1-python.pkg.dev/assured-oss-457903/python-libs/ + +jobs: + determine-package: + runs-on: ubuntu-latest + outputs: + package_dir: ${{ steps.parse.outputs.package_dir }} + package_name: ${{ steps.parse.outputs.package_name }} + version: ${{ steps.parse.outputs.version }} + steps: + - name: Parse tag + id: parse + run: | + TAG="${{ github.ref_name }}" + echo "Processing tag: $TAG" + + if [[ "$TAG" == cloudflare-auth-v* ]]; then + echo "package_dir=packages/cloudflare-auth" >> $GITHUB_OUTPUT + echo "package_name=byronwilliamscpa-cloudflare-auth" >> $GITHUB_OUTPUT + echo "version=${TAG#cloudflare-auth-v}" >> $GITHUB_OUTPUT + elif [[ "$TAG" == gcs-utilities-v* ]]; then + echo "package_dir=packages/gcs-utilities" >> $GITHUB_OUTPUT + echo "package_name=byronwilliamscpa-gcs-utilities" >> $GITHUB_OUTPUT + echo "version=${TAG#gcs-utilities-v}" >> $GITHUB_OUTPUT + else + echo "::error::Unknown tag format: $TAG" + exit 1 + fi + + build-and-publish: + needs: determine-package + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Fetch secrets from Infisical + uses: Infisical/secrets-action@v1.0.7 + with: + client-id: ${{ secrets.INFISICAL_CLIENT_ID }} + client-secret: ${{ secrets.INFISICAL_CLIENT_SECRET }} + env-slug: ${{ env.INFISICAL_ENV }} + project-slug: ${{ env.INFISICAL_PROJECT }} + domain: ${{ env.INFISICAL_DOMAIN }} + + - name: Install uv + uses: astral-sh/setup-uv@6b9c6063abd6010835644d4c2e1bef4cf5cd0fca # v6.0.1 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.12 + + - name: Verify version matches tag + working-directory: ${{ needs.determine-package.outputs.package_dir }} + run: | + TOML_VERSION=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/') + TAG_VERSION="${{ needs.determine-package.outputs.version }}" + if [[ "$TOML_VERSION" != "$TAG_VERSION" ]]; then + echo "::error::Version mismatch! pyproject.toml=$TOML_VERSION, tag=$TAG_VERSION" + exit 1 + fi + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@71f986410dfbc7added4569d411d040a91dc6935 # v2.1.8 + with: + credentials_json: ${{ env.GCP_SA_KEY_BASE64 }} + + - name: Install keyring for Artifact Registry + run: pip install keyrings.google-artifactregistry-auth + + - name: Build package + working-directory: ${{ needs.determine-package.outputs.package_dir }} + run: uv build + + - name: Publish to Artifact Registry + working-directory: ${{ needs.determine-package.outputs.package_dir }} + run: uv publish --publish-url ${{ env.ARTIFACT_REGISTRY_URL }} + + - name: Job summary + run: | + echo "## πŸ“¦ Published: ${{ needs.determine-package.outputs.package_name }} v${{ needs.determine-package.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Registry: \`us-central1-python.pkg.dev/assured-oss-457903/python-libs\`" >> $GITHUB_STEP_SUMMARY +``` + +--- + +## Part 3: Fix cloudflare-auth Imports + +The `cloudflare-auth` package has imports that reference `src.cloudflare_auth` and `src.config.settings`. These must be fixed before publishing. + +### 3.1 Add settings.py + +Create `packages/cloudflare-auth/src/cloudflare_auth/settings.py`: + +```python +"""Cloudflare Access configuration settings. + +Hybrid approach: reads from environment by default, but accepts injected settings. +""" + +from functools import lru_cache +from typing import Optional + +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class CloudflareSettings(BaseSettings): + """Configuration for Cloudflare Access authentication.""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + case_sensitive=False, + ) + + # Required + cloudflare_team_domain: str = Field(default="", alias="CLOUDFLARE_TEAM_DOMAIN") + cloudflare_audience_tag: str = Field(default="", alias="CLOUDFLARE_AUDIENCE_TAG") + cloudflare_enabled: bool = Field(default=True, alias="CLOUDFLARE_ENABLED") + + # Headers + jwt_header_name: str = Field(default="Cf-Access-Jwt-Assertion", alias="CF_JWT_HEADER") + email_header_name: str = Field(default="Cf-Access-Authenticated-User-Email", alias="CF_EMAIL_HEADER") + + # Security + require_email_verification: bool = Field(default=True, alias="CF_REQUIRE_EMAIL_VERIFICATION") + log_auth_failures: bool = Field(default=True, alias="CF_LOG_AUTH_FAILURES") + require_cloudflare_headers: bool = Field(default=True, alias="CF_REQUIRE_CLOUDFLARE_HEADERS") + + # Access control + allowed_email_domains: list[str] = Field(default_factory=list, alias="CF_ALLOWED_EMAIL_DOMAINS") + allowed_tunnel_ips: list[str] = Field(default_factory=list, alias="CF_ALLOWED_TUNNEL_IPS") + + # Cookies + cookie_domain: Optional[str] = Field(default=None, alias="CF_COOKIE_DOMAIN") + cookie_path: str = Field(default="/", alias="CF_COOKIE_PATH") + cookie_secure: bool = Field(default=True, alias="CF_COOKIE_SECURE") + cookie_samesite: str = Field(default="lax", alias="CF_COOKIE_SAMESITE") + + # JWT + jwt_algorithm: str = Field(default="RS256", alias="CF_JWT_ALGORITHM") + jwt_cache_max_keys: int = Field(default=16, alias="CF_JWT_CACHE_MAX_KEYS") + + @field_validator("allowed_email_domains", "allowed_tunnel_ips", mode="before") + @classmethod + def parse_comma_separated(cls, v): + if isinstance(v, str): + return [item.strip() for item in v.split(",") if item.strip()] if v.strip() else [] + return v or [] + + @property + def issuer(self) -> str: + if not self.cloudflare_team_domain: + return "" + domain = self.cloudflare_team_domain.rstrip("/") + return f"https://{domain}" if not domain.startswith("https://") else domain + + @property + def certs_url(self) -> str: + return f"{self.issuer}/cdn-cgi/access/certs" if self.issuer else "" + + def is_email_allowed(self, email: str) -> bool: + if not self.allowed_email_domains: + return True + if "@" not in email: + return False + domain = email.split("@")[-1].lower() + return domain in [d.lower() for d in self.allowed_email_domains] + + +_settings_instance: Optional[CloudflareSettings] = None + + +def get_cloudflare_settings() -> CloudflareSettings: + """Get default settings (singleton, reads from environment).""" + global _settings_instance + if _settings_instance is None: + _settings_instance = CloudflareSettings() + return _settings_instance + + +def reset_settings() -> None: + """Reset singleton (for testing).""" + global _settings_instance + _settings_instance = None +``` + +### 3.2 Update pyproject.toml + +Add `pydantic-settings` to dependencies in `packages/cloudflare-auth/pyproject.toml`: + +```toml +dependencies = [ + "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", # ADD THIS LINE + "pyjwt>=2.8.0", + "cryptography>=41.0.0", + "httpx>=0.25.0", +] +``` + +### 3.3 Fix Imports + +Update these files to change imports from `src.` to relative/package imports: + +**Files to update:** +- `__init__.py` +- `middleware.py` +- `middleware_enhanced.py` +- `validators.py` + +**Find and replace:** + +| Find | Replace With | +|------|--------------| +| `from src.cloudflare_auth.` | `from cloudflare_auth.` or `from .` | +| `from src.config.settings import CloudflareSettings, get_cloudflare_settings` | `from cloudflare_auth.settings import CloudflareSettings, get_cloudflare_settings` | + +**Example for `__init__.py`:** + +```python +# Change FROM: +from src.cloudflare_auth.middleware import CloudflareAuthMiddleware, get_current_user + +# Change TO: +from cloudflare_auth.middleware import CloudflareAuthMiddleware, get_current_user +# OR use relative imports: +from .middleware import CloudflareAuthMiddleware, get_current_user +``` + +### 3.4 Test Locally + +```bash +cd packages/cloudflare-auth +uv sync +uv build + +# Verify wheel contents +unzip -l dist/*.whl +``` + +--- + +## Part 4: Publishing Packages + +### 4.1 First Publish: gcs-utilities (Test Run) + +Start with `gcs-utilities` since it has no import issues: + +```bash +# Ensure you're on main with latest changes +git checkout main +git pull + +# Tag the release +git tag gcs-utilities-v0.1.0 +git push --tags +``` + +**Watch the Actions tab.** The workflow should: +1. βœ… Parse the tag +2. βœ… Fetch secrets from Infisical +3. βœ… Authenticate to GCP +4. βœ… Build the package +5. βœ… Publish to Artifact Registry + +### 4.2 Verify Publication + +```bash +# Install keyring +pip install keyrings.google-artifactregistry-auth + +# Authenticate +gcloud auth application-default login + +# Check package is available +pip index versions byronwilliamscpa-gcs-utilities \ + --index-url https://us-central1-python.pkg.dev/assured-oss-457903/python-libs/simple +``` + +### 4.3 Publish cloudflare-auth + +After fixing imports (Part 3): + +```bash +git add -A +git commit -m "fix: update imports for package distribution" +git push + +git tag cloudflare-auth-v0.1.0 +git push --tags +``` + +--- + +## Part 5: Adding New Packages + +To add a new package to the monorepo: + +### 5.1 Create Package Structure + +```bash +mkdir -p packages/new-package/src/new_package +mkdir -p packages/new-package/tests +``` + +### 5.2 Create pyproject.toml + +```toml +[project] +name = "byronwilliamscpa-new-package" +version = "0.1.0" +description = "Description here" +readme = "README.md" +requires-python = ">=3.10,<3.15" +license = {text = "MIT"} +authors = [{name = "Byron Williams", email = "byronawilliams@gmail.com"}] + +dependencies = [] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/new_package"] + +[tool.semantic_release] +version_toml = ["pyproject.toml:project.version"] +tag_format = "new-package-v{version}" +``` + +### 5.3 Update Workflow + +Add the new tag pattern to `.github/workflows/publish.yml`: + +```yaml +on: + push: + tags: + - 'cloudflare-auth-v*' + - 'gcs-utilities-v*' + - 'new-package-v*' # ADD THIS +``` + +Update the parse step: + +```yaml +elif [[ "$TAG" == new-package-v* ]]; then + echo "package_dir=packages/new-package" >> $GITHUB_OUTPUT + echo "package_name=byronwilliamscpa-new-package" >> $GITHUB_OUTPUT + echo "version=${TAG#new-package-v}" >> $GITHUB_OUTPUT +``` + +--- + +## Troubleshooting + +### "Infisical: Access denied" + +- Verify machine identity has access to the project +- Check environment is `prod` (not `dev`) +- Verify Client ID and Secret are correct + +### "GCP: Permission denied" + +- The secret `GCP_SA_KEY_BASE64` must be valid base64 +- Service account needs `roles/artifactregistry.writer` on the repository + +### "Version mismatch" error + +- Update `version` in `pyproject.toml` before tagging +- Tag format must match: `{package}-v{version}` + +### Build fails with import errors + +- Ensure all `src.` imports are changed to relative/package imports +- Run `uv build` locally to test before pushing + +--- + +## Quick Reference + +| Item | Value | +|------|-------| +| **Artifact Registry** | `us-central1-python.pkg.dev/assured-oss-457903/python-libs` | +| **Service Account** | `assured-oss-accessor@assured-oss-457903.iam.gserviceaccount.com` | +| **Infisical URL** | https://secrets.byronwilliamscpa.com | +| **Infisical Project** | `python-libs` | +| **GitHub Secrets** | `INFISICAL_CLIENT_ID`, `INFISICAL_CLIENT_SECRET` | + +### Tag Formats + +| Package | Tag Format | Example | +|---------|------------|---------| +| cloudflare-auth | `cloudflare-auth-v{version}` | `cloudflare-auth-v0.1.0` | +| gcs-utilities | `gcs-utilities-v{version}` | `gcs-utilities-v1.2.3` | + +--- + +## Contacts + +- **Infrastructure Questions:** Byron Williams +- **Infisical Issues:** Check https://secrets.byronwilliamscpa.com status +- **GCP Issues:** Check `assured-oss-457903` project in GCP Console \ No newline at end of file diff --git a/ip_groups.example.yaml b/ip_groups.example.yaml new file mode 100644 index 0000000..f1e80fd --- /dev/null +++ b/ip_groups.example.yaml @@ -0,0 +1,144 @@ +# IP Range Groups Configuration +# ============================== +# This file defines IP range groups that are synced to Cloudflare IP lists. +# Each group becomes a Cloudflare list that can be referenced in Access policies. +# +# Usage: +# python -m cloudflare_api.ip_groups.cli sync # Sync all groups +# python -m cloudflare_api.ip_groups.cli sync -g github # Sync specific group +# python -m cloudflare_api.ip_groups.cli preview github # Preview changes +# python -m cloudflare_api.ip_groups.cli list # List all groups + +version: "1.0" + +# How long to cache fetched IPs (in seconds) +# Dynamic sources (GitHub, AWS, etc.) are fetched at most once per TTL +cache_ttl_seconds: 3600 # 1 hour + +# Optional prefix for all Cloudflare list names +# Useful for namespacing: "myapp-" would create lists like "myapp-github-actions" +cloudflare_list_prefix: "" + +groups: + # ============================================================================ + # Home/Office Network + # ============================================================================ + - name: home-network + cloudflare_list_name: home-network + description: "Home and office IP addresses" + enabled: true + tags: ["trusted", "internal"] + sources: + - type: static + ips: + - "203.0.113.50" # Home IP (replace with your IP) + - "198.51.100.0/24" # Office network (replace with your range) + + # ============================================================================ + # GitHub Actions + # ============================================================================ + # GitHub's IP ranges for webhooks, actions, and other services + # See: https://api.github.com/meta + - name: github + cloudflare_list_name: github-ips + description: "GitHub Actions and webhook IPs" + enabled: true + tags: ["ci-cd", "github"] + sources: + - type: github + # Services to include (comment out to include all) + services: + - actions # GitHub Actions runners + - hooks # Webhook delivery IPs + - dependabot # Dependabot IPs + # Optional: filter by IP version + # ip_version: 4 # Only IPv4 + + # ============================================================================ + # Google Cloud + # ============================================================================ + # Google Cloud Platform IP ranges + # See: https://www.gstatic.com/ipranges/cloud.json + - name: google-cloud + cloudflare_list_name: gcp-ips + description: "Google Cloud Platform IPs" + enabled: true + tags: ["cloud", "gcp"] + sources: + - type: google_cloud + # Optional: filter by region + # regions: + # - us-central1 + # - us-east1 + # Optional: filter by IP version + ip_version: 4 + + # ============================================================================ + # AWS + # ============================================================================ + # Amazon Web Services IP ranges + # See: https://ip-ranges.amazonaws.com/ip-ranges.json + - name: aws + cloudflare_list_name: aws-ips + description: "AWS service IPs" + enabled: false # Disabled by default (very large range) + tags: ["cloud", "aws"] + sources: + - type: aws + # Filter by service (recommended - full list is huge) + services: + - CLOUDFRONT + - API_GATEWAY + # Filter by region + regions: + - us-east-1 + - us-west-2 + ip_version: 4 + + # ============================================================================ + # CI/CD Combined + # ============================================================================ + # Combine multiple sources into one list + - name: ci-cd + cloudflare_list_name: ci-cd-ips + description: "All CI/CD service IPs" + enabled: true + tags: ["ci-cd"] + sources: + # GitHub Actions + - type: github + services: + - actions + # Could add more CI services here: + # - type: url + # url: "https://circleci.com/ips.txt" + + # ============================================================================ + # Custom URL Source + # ============================================================================ + # Fetch IPs from any URL that returns IP addresses + - name: custom-service + cloudflare_list_name: custom-service-ips + description: "IPs from custom service" + enabled: false + sources: + # Plain text format (one IP per line) + - type: url + url: "https://example.com/allowed-ips.txt" + + # JSON format with JSONPath extraction + # - type: url + # url: "https://api.example.com/ip-ranges" + # json_path: "ranges[*].cidr" + + # ============================================================================ + # Cloudflare IPs + # ============================================================================ + # Cloudflare's own IP ranges (useful for certain configurations) + - name: cloudflare + cloudflare_list_name: cloudflare-ips + description: "Cloudflare network IPs" + enabled: false + sources: + - type: cloudflare + ip_version: 4 # IPv4 only diff --git a/packages/cloudflare-api/README.md b/packages/cloudflare-api/README.md new file mode 100644 index 0000000..9493e40 --- /dev/null +++ b/packages/cloudflare-api/README.md @@ -0,0 +1,142 @@ +# Cloudflare API Client + +A Python client for managing Cloudflare resources including IP lists, firewall rules, and Access policies. + +## Installation + +```bash +pip install byronwilliamscpa-cloudflare-api +``` + +Or install from Artifact Registry: + +```bash +pip install --index-url https://us-central1-python.pkg.dev/assured-oss-457903/python-libs/simple/ byronwilliamscpa-cloudflare-api +``` + +## Features + +- IP List management (create, update, delete lists and items) +- Async bulk operations with status tracking +- Pydantic settings for configuration +- Type-safe API with full type hints + +## Configuration + +Set environment variables: + +```bash +export CLOUDFLARE_API_TOKEN="your-api-token" +export CLOUDFLARE_ACCOUNT_ID="your-account-id" +``` + +Or use a `.env` file: + +```env +CLOUDFLARE_API_TOKEN=your-api-token +CLOUDFLARE_ACCOUNT_ID=your-account-id +``` + +## Usage + +### Basic Usage + +```python +from cloudflare_api import CloudflareAPIClient, get_cloudflare_api_settings + +# Using environment variables +client = CloudflareAPIClient() + +# List all IP lists +lists = client.list_ip_lists() +for ip_list in lists: + print(f"{ip_list.name}: {ip_list.num_items} items") +``` + +### Managing IP Lists + +```python +from cloudflare_api import CloudflareAPIClient + +client = CloudflareAPIClient() + +# Create a new IP list +new_list = client.create_ip_list( + name="blocked-ips", + kind="ip", + description="IPs to block" +) + +# Add IPs to the list +client.add_ip_list_items( + list_id=new_list.id, + items=[ + {"ip": "192.168.1.1", "comment": "Bad actor"}, + {"ip": "10.0.0.0/8", "comment": "Internal range"}, + ] +) + +# Get list contents +items = client.get_ip_list_items(list_id=new_list.id) +for item in items: + print(f"{item.ip} - {item.comment}") + +# Replace all items in a list +client.replace_ip_list_items( + list_id=new_list.id, + items=[ + {"ip": "203.0.113.0/24", "comment": "New blocklist"}, + ] +) + +# Delete specific items +client.delete_ip_list_items( + list_id=new_list.id, + item_ids=["item-id-1", "item-id-2"] +) + +# Delete the entire list +client.delete_ip_list(list_id=new_list.id) +``` + +### Async Operations + +IP list item operations are asynchronous. You can track operation status: + +```python +# Add items and get operation ID +operation_id = client.add_ip_list_items( + list_id="list-id", + items=[{"ip": "1.2.3.4"}], + return_operation_id=True +) + +# Check operation status +status = client.get_bulk_operation_status(operation_id) +print(f"Status: {status.status}") # pending, running, completed, failed +``` + +### Custom Settings + +```python +from cloudflare_api import CloudflareAPIClient, CloudflareAPISettings + +settings = CloudflareAPISettings( + cloudflare_api_token="your-token", + cloudflare_account_id="your-account-id", +) + +client = CloudflareAPIClient(settings=settings) +``` + +## API Token Permissions + +Your Cloudflare API token needs the following permissions: + +- **Account > Account Filter Lists > Edit** - For IP list management +- **Account > Account Firewall Access Rules > Edit** - For firewall rules (if needed) +- **Account > Access: Apps and Policies > Edit** - For Access policies (if needed) + +## License + +MIT License - see LICENSE file for details. diff --git a/packages/cloudflare-api/pyproject.toml b/packages/cloudflare-api/pyproject.toml new file mode 100644 index 0000000..b67a2e7 --- /dev/null +++ b/packages/cloudflare-api/pyproject.toml @@ -0,0 +1,71 @@ +[project] +name = "byronwilliamscpa-cloudflare-api" +version = "0.1.0" +description = "Cloudflare API client for managing IP lists, firewall rules, and Access policies" +readme = "README.md" +requires-python = ">=3.10,<3.15" +license = {text = "MIT"} +authors = [ + {name = "Byron Williams", email = "byronawilliams@gmail.com"} +] +keywords = ["cloudflare", "api", "firewall", "ip-list", "access", "security"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Security", + "Topic :: Internet :: WWW/HTTP", + "Typing :: Typed", +] + +dependencies = [ + "cloudflare>=4.0.0", + "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", + "httpx>=0.25.0", + "pyyaml>=6.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.4.0", + "pytest-cov>=4.1.0", + "pytest-asyncio>=0.21.0", + "respx>=0.21.0", +] + +[project.scripts] +cloudflare-ip-groups = "cloudflare_api.ip_groups.cli:main" + +[project.urls] +Homepage = "https://github.com/ByronWilliamsCPA/python-libs" +Repository = "https://github.com/ByronWilliamsCPA/python-libs" +Documentation = "https://github.com/ByronWilliamsCPA/python-libs#readme" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/cloudflare_api"] + +# Per-package semantic release configuration +[tool.semantic_release] +version_toml = ["pyproject.toml:project.version"] +tag_format = "cloudflare-api-v{version}" + +[tool.semantic_release.commit_parser_options] +allowed_tags = ["feat", "fix", "perf", "refactor", "docs", "style", "test", "build", "ci", "chore"] +minor_tags = ["feat"] +patch_tags = ["fix", "perf"] + +[tool.semantic_release.changelog] +changelog_file = "CHANGELOG.md" + +[tool.semantic_release.branches.main] +match = "(main|master)" +prerelease = false diff --git a/packages/cloudflare-api/src/cloudflare_api/__init__.py b/packages/cloudflare-api/src/cloudflare_api/__init__.py new file mode 100644 index 0000000..58bc1d7 --- /dev/null +++ b/packages/cloudflare-api/src/cloudflare_api/__init__.py @@ -0,0 +1,73 @@ +"""Cloudflare API client package. + +Provides a high-level client for managing Cloudflare resources including +IP lists, firewall rules, and Access policies. + +Example: + ```python + from cloudflare_api import CloudflareAPIClient + + client = CloudflareAPIClient() + + # List all IP lists + lists = client.list_ip_lists() + + # Create and populate a list + new_list = client.create_ip_list("blocked-ips", description="Bad actors") + client.add_ip_list_items(new_list.id, [{"ip": "1.2.3.4", "comment": "Spam"}]) + ``` + +IP Groups Example: + ```python + from cloudflare_api.ip_groups import IPGroupManager + + manager = IPGroupManager.from_config("ip_groups.yaml") + manager.sync_all() # Updates all Cloudflare lists from configured sources + ``` +""" + +from cloudflare_api.client import CloudflareAPIClient +from cloudflare_api.exceptions import ( + CloudflareAPIError, + CloudflareAuthError, + CloudflareBulkOperationError, + CloudflareConflictError, + CloudflareNotFoundError, + CloudflareRateLimitError, + CloudflareValidationError, +) +from cloudflare_api.models import ( + BulkOperation, + BulkOperationStatus, + IPList, + IPListItem, + IPListItemInput, + ListKind, +) +from cloudflare_api.settings import ( + CloudflareAPISettings, + get_cloudflare_api_settings, + reset_settings, +) + +__all__ = [ + "BulkOperation", + "BulkOperationStatus", + "CloudflareAPIClient", + "CloudflareAPIError", + "CloudflareAPISettings", + "CloudflareAuthError", + "CloudflareBulkOperationError", + "CloudflareConflictError", + "CloudflareNotFoundError", + "CloudflareRateLimitError", + "CloudflareValidationError", + "IPList", + "IPListItem", + "IPListItemInput", + "ListKind", + "get_cloudflare_api_settings", + "reset_settings", +] + +__version__ = "0.1.0" diff --git a/packages/cloudflare-api/src/cloudflare_api/client.py b/packages/cloudflare-api/src/cloudflare_api/client.py new file mode 100644 index 0000000..374b5fe --- /dev/null +++ b/packages/cloudflare-api/src/cloudflare_api/client.py @@ -0,0 +1,676 @@ +"""Cloudflare API client for managing IP lists and other resources. + +Uses the official Cloudflare Python SDK for API operations. +""" + +import logging +import time +from typing import Any + +from cloudflare import Cloudflare +from cloudflare._exceptions import ( + APIConnectionError, + APIStatusError, + AuthenticationError, + BadRequestError, + NotFoundError, + RateLimitError, +) + +from cloudflare_api.exceptions import ( + CloudflareAPIError, + CloudflareAuthError, + CloudflareBulkOperationError, + CloudflareConflictError, + CloudflareNotFoundError, + CloudflareRateLimitError, + CloudflareValidationError, +) +from cloudflare_api.models import ( + BulkOperation, + BulkOperationStatus, + IPList, + IPListItem, + IPListItemInput, + ListKind, +) +from cloudflare_api.settings import CloudflareAPISettings, get_cloudflare_api_settings + +logger = logging.getLogger(__name__) + + +class CloudflareAPIClient: + """Client for Cloudflare API operations. + + Provides methods for managing IP lists, firewall rules, and other + Cloudflare resources using the official SDK. + + Example: + ```python + client = CloudflareAPIClient() + + # List all IP lists + lists = client.list_ip_lists() + + # Create a new list + new_list = client.create_ip_list("blocked-ips", kind="ip") + + # Add items + client.add_ip_list_items(new_list.id, [{"ip": "1.2.3.4"}]) + ``` + """ + + def __init__( + self, + settings: CloudflareAPISettings | None = None, + ) -> None: + """Initialize the Cloudflare API client. + + Args: + settings: Optional settings. If not provided, reads from environment. + + Raises: + CloudflareAuthError: If authentication credentials are missing. + """ + self.settings = settings or get_cloudflare_api_settings() + self._client = Cloudflare( + api_token=self.settings.get_token_value(), + ) + self._account_id = self.settings.cloudflare_account_id + + logger.info( + "Initialized Cloudflare API client for account %s", + self._account_id[:8] + "...", + ) + + def _handle_api_error(self, error: Exception) -> None: + """Convert SDK exceptions to our custom exceptions. + + Args: + error: Exception from the Cloudflare SDK. + + Raises: + CloudflareAuthError: For authentication failures. + CloudflareRateLimitError: For rate limit errors. + CloudflareNotFoundError: For missing resources. + CloudflareValidationError: For invalid requests. + CloudflareAPIError: For other API errors. + """ + if isinstance(error, AuthenticationError): + msg = "Authentication failed. Check your API token." + raise CloudflareAuthError(msg, code=401) from error + + if isinstance(error, RateLimitError): + msg = "Rate limit exceeded. Please wait before retrying." + raise CloudflareRateLimitError(msg) from error + + if isinstance(error, NotFoundError): + raise CloudflareNotFoundError( + str(error), + code=404, + ) from error + + if isinstance(error, BadRequestError): + raise CloudflareValidationError( + str(error), + code=400, + ) from error + + if isinstance(error, APIConnectionError): + msg = f"Connection error: {error}" + raise CloudflareAPIError(msg) from error + + if isinstance(error, APIStatusError): + raise CloudflareAPIError( + str(error), + code=getattr(error, "status_code", None), + ) from error + + raise CloudflareAPIError(str(error)) from error + + # ========================================================================= + # IP List Operations + # ========================================================================= + + def list_ip_lists(self) -> list[IPList]: + """List all IP lists in the account. + + Returns: + List of IPList objects. + + Raises: + CloudflareAPIError: If the API request fails. + """ + try: + response = self._client.rules.lists.list(account_id=self._account_id) + lists = [] + for item in response: + lists.append( + IPList( + id=item.id, + name=item.name, + description=item.description, + kind=ListKind(item.kind) if item.kind else ListKind.IP, + num_items=item.num_items or 0, + num_referencing_filters=item.num_referencing_filters or 0, + created_on=item.created_on, + modified_on=item.modified_on, + ) + ) + logger.debug("Listed %d IP lists", len(lists)) + return lists + except Exception as e: + self._handle_api_error(e) + raise # Unreachable but satisfies type checker + + def get_ip_list(self, list_id: str) -> IPList: + """Get details of a specific IP list. + + Args: + list_id: The list identifier. + + Returns: + IPList object. + + Raises: + CloudflareNotFoundError: If the list doesn't exist. + CloudflareAPIError: If the API request fails. + """ + try: + item = self._client.rules.lists.get( + list_id=list_id, + account_id=self._account_id, + ) + return IPList( + id=item.id, + name=item.name, + description=item.description, + kind=ListKind(item.kind) if item.kind else ListKind.IP, + num_items=item.num_items or 0, + num_referencing_filters=item.num_referencing_filters or 0, + created_on=item.created_on, + modified_on=item.modified_on, + ) + except Exception as e: + self._handle_api_error(e) + raise + + def get_ip_list_by_name(self, name: str) -> IPList | None: + """Get an IP list by name. + + Args: + name: The list name to search for. + + Returns: + IPList if found, None otherwise. + + Raises: + CloudflareAPIError: If the API request fails. + """ + lists = self.list_ip_lists() + for ip_list in lists: + if ip_list.name == name: + return ip_list + return None + + def create_ip_list( + self, + name: str, + kind: str = "ip", + description: str | None = None, + ) -> IPList: + """Create a new IP list. + + Args: + name: List name (must be unique per account). + kind: Type of list (ip, redirect, hostname, asn). + description: Optional description. + + Returns: + The created IPList. + + Raises: + CloudflareConflictError: If a list with this name already exists. + CloudflareValidationError: If the name or kind is invalid. + CloudflareAPIError: If the API request fails. + """ + try: + response = self._client.rules.lists.create( + account_id=self._account_id, + kind=kind, + name=name, + description=description, + ) + logger.info("Created IP list '%s' with ID %s", name, response.id) + return IPList( + id=response.id, + name=response.name, + description=response.description, + kind=ListKind(response.kind) if response.kind else ListKind.IP, + num_items=response.num_items or 0, + num_referencing_filters=response.num_referencing_filters or 0, + created_on=response.created_on, + modified_on=response.modified_on, + ) + except BadRequestError as e: + if "already exists" in str(e).lower(): + msg = f"A list named '{name}' already exists" + raise CloudflareConflictError(msg, code=409) from e + self._handle_api_error(e) + raise + except Exception as e: + self._handle_api_error(e) + raise + + def update_ip_list( + self, + list_id: str, + description: str | None = None, + ) -> IPList: + """Update an IP list's description. + + Args: + list_id: The list identifier. + description: New description. + + Returns: + The updated IPList. + + Raises: + CloudflareNotFoundError: If the list doesn't exist. + CloudflareAPIError: If the API request fails. + """ + try: + response = self._client.rules.lists.update( + list_id=list_id, + account_id=self._account_id, + description=description, + ) + logger.info("Updated IP list %s", list_id) + return IPList( + id=response.id, + name=response.name, + description=response.description, + kind=ListKind(response.kind) if response.kind else ListKind.IP, + num_items=response.num_items or 0, + num_referencing_filters=response.num_referencing_filters or 0, + created_on=response.created_on, + modified_on=response.modified_on, + ) + except Exception as e: + self._handle_api_error(e) + raise + + def delete_ip_list(self, list_id: str) -> bool: + """Delete an IP list and all its items. + + Args: + list_id: The list identifier. + + Returns: + True if deleted successfully. + + Raises: + CloudflareNotFoundError: If the list doesn't exist. + CloudflareConflictError: If the list is in use by firewall rules. + CloudflareAPIError: If the API request fails. + """ + try: + self._client.rules.lists.delete( + list_id=list_id, + account_id=self._account_id, + ) + logger.info("Deleted IP list %s", list_id) + return True + except BadRequestError as e: + if "in use" in str(e).lower() or "referenced" in str(e).lower(): + msg = ( + f"Cannot delete list {list_id}: it is referenced by firewall rules" + ) + raise CloudflareConflictError(msg, code=409) from e + self._handle_api_error(e) + raise + except Exception as e: + self._handle_api_error(e) + raise + + # ========================================================================= + # IP List Item Operations + # ========================================================================= + + def get_ip_list_items(self, list_id: str) -> list[IPListItem]: + """Get all items in an IP list. + + Args: + list_id: The list identifier. + + Returns: + List of IPListItem objects. + + Raises: + CloudflareNotFoundError: If the list doesn't exist. + CloudflareAPIError: If the API request fails. + """ + try: + response = self._client.rules.lists.items.list( + list_id=list_id, + account_id=self._account_id, + ) + items = [] + for item in response: + items.append( + IPListItem( + id=item.id, + ip=item.ip, + comment=item.comment, + created_on=item.created_on, + modified_on=item.modified_on, + ) + ) + logger.debug("Retrieved %d items from list %s", len(items), list_id) + return items + except Exception as e: + self._handle_api_error(e) + raise + + def add_ip_list_items( + self, + list_id: str, + items: list[dict[str, Any] | IPListItemInput], + wait_for_completion: bool = True, + ) -> str | None: + """Add items to an IP list. + + This is an asynchronous operation. By default, waits for completion. + + Args: + list_id: The list identifier. + items: List of items to add. Each item should have 'ip' and + optionally 'comment'. + wait_for_completion: Whether to wait for the operation to complete. + + Returns: + Operation ID if not waiting, None if completed. + + Raises: + CloudflareNotFoundError: If the list doesn't exist. + CloudflareBulkOperationError: If the operation fails. + CloudflareConflictError: If another bulk operation is in progress. + CloudflareAPIError: If the API request fails. + """ + try: + # Convert to API format + api_items = [] + for item in items: + if isinstance(item, IPListItemInput): + api_items.append(item.to_api_dict()) + else: + api_items.append(item) + + response = self._client.rules.lists.items.create( + list_id=list_id, + account_id=self._account_id, + body=api_items, + ) + + operation_id = response.operation_id + logger.info( + "Started add operation %s for %d items to list %s", + operation_id, + len(items), + list_id, + ) + + if wait_for_completion and operation_id: + self._wait_for_bulk_operation(operation_id) + return None + + return operation_id + except BadRequestError as e: + if "pending" in str(e).lower(): + msg = "Another bulk operation is already in progress" + raise CloudflareConflictError(msg, code=409) from e + self._handle_api_error(e) + raise + except Exception as e: + self._handle_api_error(e) + raise + + def replace_ip_list_items( + self, + list_id: str, + items: list[dict[str, Any] | IPListItemInput], + wait_for_completion: bool = True, + ) -> str | None: + """Replace all items in an IP list. + + Removes all existing items and adds the provided items. + This is an asynchronous operation. + + Args: + list_id: The list identifier. + items: List of items to set. Each item should have 'ip' and + optionally 'comment'. + wait_for_completion: Whether to wait for the operation to complete. + + Returns: + Operation ID if not waiting, None if completed. + + Raises: + CloudflareNotFoundError: If the list doesn't exist. + CloudflareBulkOperationError: If the operation fails. + CloudflareConflictError: If another bulk operation is in progress. + CloudflareAPIError: If the API request fails. + """ + try: + # Convert to API format + api_items = [] + for item in items: + if isinstance(item, IPListItemInput): + api_items.append(item.to_api_dict()) + else: + api_items.append(item) + + response = self._client.rules.lists.items.update( + list_id=list_id, + account_id=self._account_id, + body=api_items, + ) + + operation_id = response.operation_id + logger.info( + "Started replace operation %s with %d items for list %s", + operation_id, + len(items), + list_id, + ) + + if wait_for_completion and operation_id: + self._wait_for_bulk_operation(operation_id) + return None + + return operation_id + except BadRequestError as e: + if "pending" in str(e).lower(): + msg = "Another bulk operation is already in progress" + raise CloudflareConflictError(msg, code=409) from e + self._handle_api_error(e) + raise + except Exception as e: + self._handle_api_error(e) + raise + + def delete_ip_list_items( + self, + list_id: str, + item_ids: list[str], + wait_for_completion: bool = True, + ) -> str | None: + """Delete specific items from an IP list. + + Args: + list_id: The list identifier. + item_ids: List of item IDs to delete. + wait_for_completion: Whether to wait for the operation to complete. + + Returns: + Operation ID if not waiting, None if completed. + + Raises: + CloudflareNotFoundError: If the list doesn't exist. + CloudflareBulkOperationError: If the operation fails. + CloudflareAPIError: If the API request fails. + """ + try: + # Format items for deletion + items_to_delete = [{"id": item_id} for item_id in item_ids] + + response = self._client.rules.lists.items.delete( + list_id=list_id, + account_id=self._account_id, + items=items_to_delete, + ) + + operation_id = response.operation_id + logger.info( + "Started delete operation %s for %d items from list %s", + operation_id, + len(item_ids), + list_id, + ) + + if wait_for_completion and operation_id: + self._wait_for_bulk_operation(operation_id) + return None + + return operation_id + except Exception as e: + self._handle_api_error(e) + raise + + # ========================================================================= + # Bulk Operation Helpers + # ========================================================================= + + def get_bulk_operation_status(self, operation_id: str) -> BulkOperation: + """Get the status of a bulk operation. + + Args: + operation_id: The operation identifier. + + Returns: + BulkOperation with current status. + + Raises: + CloudflareNotFoundError: If the operation doesn't exist. + CloudflareAPIError: If the API request fails. + """ + try: + response = self._client.rules.lists.bulk_operations.get( + operation_id=operation_id, + account_id=self._account_id, + ) + return BulkOperation( + id=response.id, + status=BulkOperationStatus(response.status), + error=response.error, + completed=response.completed, + ) + except Exception as e: + self._handle_api_error(e) + raise + + def _wait_for_bulk_operation(self, operation_id: str) -> BulkOperation: + """Wait for a bulk operation to complete. + + Args: + operation_id: The operation identifier. + + Returns: + The final BulkOperation status. + + Raises: + CloudflareBulkOperationError: If the operation fails or times out. + """ + start_time = time.time() + timeout = self.settings.bulk_operation_timeout + poll_interval = self.settings.bulk_operation_poll_interval + + while True: + elapsed = time.time() - start_time + if elapsed > timeout: + msg = f"Bulk operation {operation_id} timed out after {timeout}s" + raise CloudflareBulkOperationError( + msg, operation_id=operation_id, status="timeout" + ) + + status = self.get_bulk_operation_status(operation_id) + + if status.status == BulkOperationStatus.COMPLETED: + logger.debug("Bulk operation %s completed", operation_id) + return status + + if status.status == BulkOperationStatus.FAILED: + msg = f"Bulk operation {operation_id} failed: {status.error}" + raise CloudflareBulkOperationError( + msg, operation_id=operation_id, status="failed" + ) + + logger.debug( + "Bulk operation %s status: %s (%.1fs elapsed)", + operation_id, + status.status.value, + elapsed, + ) + time.sleep(poll_interval) + + # ========================================================================= + # Convenience Methods + # ========================================================================= + + def ensure_ip_list( + self, + name: str, + kind: str = "ip", + description: str | None = None, + ) -> IPList: + """Get or create an IP list by name. + + Args: + name: List name. + kind: Type of list if creating. + description: Description if creating. + + Returns: + The existing or newly created IPList. + + Raises: + CloudflareAPIError: If the API request fails. + """ + existing = self.get_ip_list_by_name(name) + if existing: + logger.debug("Found existing IP list '%s' (%s)", name, existing.id) + return existing + + return self.create_ip_list(name=name, kind=kind, description=description) + + def sync_ip_list( + self, + list_id: str, + ips: list[str], + comments: dict[str, str] | None = None, + ) -> None: + """Sync an IP list to contain exactly the specified IPs. + + Args: + list_id: The list identifier. + ips: List of IP addresses/CIDRs that should be in the list. + comments: Optional mapping of IP to comment. + + Raises: + CloudflareAPIError: If the API request fails. + """ + comments = comments or {} + items = [{"ip": ip, "comment": comments.get(ip)} for ip in ips] + self.replace_ip_list_items(list_id, items) + logger.info("Synced list %s with %d IPs", list_id, len(ips)) diff --git a/packages/cloudflare-api/src/cloudflare_api/exceptions.py b/packages/cloudflare-api/src/cloudflare_api/exceptions.py new file mode 100644 index 0000000..bc7bdbb --- /dev/null +++ b/packages/cloudflare-api/src/cloudflare_api/exceptions.py @@ -0,0 +1,176 @@ +"""Cloudflare API exceptions. + +Custom exceptions for Cloudflare API operations with detailed error context. +""" + +from typing import Any + + +class CloudflareAPIError(Exception): + """Base exception for Cloudflare API errors. + + Attributes: + message: Error message + code: Cloudflare error code (if available) + errors: List of error details from Cloudflare response + response: Raw response data (if available) + """ + + def __init__( + self, + message: str, + code: int | None = None, + errors: list[dict[str, Any]] | None = None, + response: dict[str, Any] | None = None, + ) -> None: + """Initialize CloudflareAPIError. + + Args: + message: Error message + code: Cloudflare error code + errors: List of error details + response: Raw response data + """ + super().__init__(message) + self.message = message + self.code = code + self.errors = errors or [] + self.response = response + + def __str__(self) -> str: + """Return string representation.""" + parts = [self.message] + if self.code: + parts.append(f"(code: {self.code})") + if self.errors: + error_msgs = [e.get("message", str(e)) for e in self.errors] + parts.append(f"Details: {'; '.join(error_msgs)}") + return " ".join(parts) + + +class CloudflareAuthError(CloudflareAPIError): + """Authentication or authorization error. + + Raised when API token is invalid, expired, or lacks required permissions. + """ + + +class CloudflareRateLimitError(CloudflareAPIError): + """Rate limit exceeded error. + + Raised when too many requests are made in a short period. + + Attributes: + retry_after: Seconds to wait before retrying (if provided) + """ + + def __init__( + self, + message: str = "Rate limit exceeded", + retry_after: int | None = None, + **kwargs: Any, + ) -> None: + """Initialize rate limit error. + + Args: + message: Error message + retry_after: Seconds to wait before retrying + **kwargs: Additional arguments for base class + """ + super().__init__(message, **kwargs) + self.retry_after = retry_after + + +class CloudflareNotFoundError(CloudflareAPIError): + """Resource not found error. + + Raised when a requested resource (list, item, zone) doesn't exist. + + Attributes: + resource_type: Type of resource not found + resource_id: ID of the missing resource + """ + + def __init__( + self, + message: str, + resource_type: str | None = None, + resource_id: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize not found error. + + Args: + message: Error message + resource_type: Type of resource (e.g., "list", "item") + resource_id: ID of the missing resource + **kwargs: Additional arguments for base class + """ + super().__init__(message, **kwargs) + self.resource_type = resource_type + self.resource_id = resource_id + + +class CloudflareValidationError(CloudflareAPIError): + """Validation error for invalid request data. + + Raised when request parameters fail Cloudflare's validation. + + Attributes: + field: Field that failed validation (if known) + """ + + def __init__( + self, + message: str, + field: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize validation error. + + Args: + message: Error message + field: Field that failed validation + **kwargs: Additional arguments for base class + """ + super().__init__(message, **kwargs) + self.field = field + + +class CloudflareBulkOperationError(CloudflareAPIError): + """Bulk operation error. + + Raised when a bulk operation fails or times out. + + Attributes: + operation_id: ID of the failed operation + status: Final status of the operation + """ + + def __init__( + self, + message: str, + operation_id: str | None = None, + status: str | None = None, + **kwargs: Any, + ) -> None: + """Initialize bulk operation error. + + Args: + message: Error message + operation_id: ID of the failed operation + status: Final status of the operation + **kwargs: Additional arguments for base class + """ + super().__init__(message, **kwargs) + self.operation_id = operation_id + self.status = status + + +class CloudflareConflictError(CloudflareAPIError): + """Conflict error. + + Raised when an operation conflicts with existing state, + e.g., creating a list with a name that already exists, + or when another bulk operation is in progress. + """ diff --git a/packages/cloudflare-api/src/cloudflare_api/ip_groups/__init__.py b/packages/cloudflare-api/src/cloudflare_api/ip_groups/__init__.py new file mode 100644 index 0000000..046b09d --- /dev/null +++ b/packages/cloudflare-api/src/cloudflare_api/ip_groups/__init__.py @@ -0,0 +1,46 @@ +"""IP Range Group management for Cloudflare. + +Provides a system for defining, fetching, and syncing IP range groups +to Cloudflare IP lists for use in Access policies. + +Example: + ```python + from cloudflare_api.ip_groups import IPGroupManager + + manager = IPGroupManager.from_config("ip_groups.yaml") + manager.sync_all() # Updates all Cloudflare lists + ``` +""" + +from cloudflare_api.ip_groups.config import ( + IPGroupConfig, + IPSourceConfig, + SourceType, + load_config, +) +from cloudflare_api.ip_groups.fetchers import ( + AWSIPFetcher, + GitHubIPFetcher, + GoogleCloudIPFetcher, + IPFetcher, + StaticIPFetcher, + URLIPFetcher, +) +from cloudflare_api.ip_groups.manager import IPGroupManager + +__all__ = [ + # Config + "IPGroupConfig", + "IPSourceConfig", + "SourceType", + "load_config", + # Fetchers + "IPFetcher", + "StaticIPFetcher", + "URLIPFetcher", + "GitHubIPFetcher", + "GoogleCloudIPFetcher", + "AWSIPFetcher", + # Manager + "IPGroupManager", +] diff --git a/packages/cloudflare-api/src/cloudflare_api/ip_groups/cli.py b/packages/cloudflare-api/src/cloudflare_api/ip_groups/cli.py new file mode 100644 index 0000000..dc5ab10 --- /dev/null +++ b/packages/cloudflare-api/src/cloudflare_api/ip_groups/cli.py @@ -0,0 +1,242 @@ +"""CLI commands for IP group management. + +Provides command-line interface for syncing IP groups to Cloudflare. +""" + +import argparse +import json +import logging +import sys +from pathlib import Path + +from cloudflare_api.ip_groups.manager import IPGroupManager + + +def setup_logging(verbose: bool = False) -> None: + """Configure logging for CLI output. + + Args: + verbose: Enable debug logging. + """ + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig( + level=level, + format="%(asctime)s - %(levelname)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + +def cmd_sync(args: argparse.Namespace) -> int: + """Sync IP groups to Cloudflare. + + Args: + args: Parsed command line arguments. + + Returns: + Exit code (0 for success). + """ + manager = IPGroupManager.from_config(args.config) + + if args.group: + results = [manager.sync_group(args.group, dry_run=args.dry_run)] + else: + results = manager.sync_all(dry_run=args.dry_run) + + # Print results + for result in results: + if result.error: + print(f"❌ {result.group_name}: {result.error}") + elif result.unchanged: + print(f"βœ“ {result.group_name}: No changes ({result.ips_count} IPs)") + else: + print( + f"βœ“ {result.group_name}: Synced {result.ips_count} IPs " + f"(+{result.added}, -{result.removed}) " + f"in {result.duration_seconds:.1f}s" + ) + + # Return error if any failed + failed = sum(1 for r in results if r.error) + return 1 if failed > 0 else 0 + + +def cmd_preview(args: argparse.Namespace) -> int: + """Preview changes for an IP group. + + Args: + args: Parsed command line arguments. + + Returns: + Exit code. + """ + manager = IPGroupManager.from_config(args.config) + preview = manager.preview_group(args.group) + + if args.json: + print(json.dumps(preview, indent=2)) + else: + print(f"\nGroup: {preview['group_name']}") + print(f"Cloudflare List: {preview['cloudflare_list_name']}") + print(f"Current IPs: {preview['current_count']}") + print(f"New IPs: {preview['new_count']}") + + if preview["to_add"]: + print(f"\nπŸ“₯ To Add ({len(preview['to_add'])}):") + for ip in preview["to_add"][:10]: + print(f" + {ip}") + if len(preview["to_add"]) > 10: + print(f" ... and {len(preview['to_add']) - 10} more") + + if preview["to_remove"]: + print(f"\nπŸ“€ To Remove ({len(preview['to_remove'])}):") + for ip in preview["to_remove"][:10]: + print(f" - {ip}") + if len(preview["to_remove"]) > 10: + print(f" ... and {len(preview['to_remove']) - 10} more") + + if not preview["will_change"]: + print("\nβœ“ No changes needed") + + return 0 + + +def cmd_list(args: argparse.Namespace) -> int: + """List all configured IP groups. + + Args: + args: Parsed command line arguments. + + Returns: + Exit code. + """ + manager = IPGroupManager.from_config(args.config) + groups = manager.list_groups() + + if args.json: + print(json.dumps(groups, indent=2)) + else: + print("\nConfigured IP Groups:") + print("-" * 60) + for group in groups: + status = "βœ“" if group["enabled"] else "βœ—" + sources = ", ".join(group["source_types"]) + print(f"{status} {group['name']}") + print(f" List: {group['cloudflare_list_name']}") + print(f" Sources: {sources}") + if group["description"]: + print(f" Desc: {group['description']}") + print() + + return 0 + + +def cmd_fetch(args: argparse.Namespace) -> int: + """Fetch and display IPs for a group (without syncing). + + Args: + args: Parsed command line arguments. + + Returns: + Exit code. + """ + manager = IPGroupManager.from_config(args.config) + + group = manager._get_group(args.group) + ips = manager.fetch_group_ips(group, use_cache=not args.no_cache) + + if args.json: + print(json.dumps({"group": args.group, "ips": ips}, indent=2)) + else: + print(f"\nFetched {len(ips)} IPs for '{args.group}':") + for ip in ips: + print(f" {ip}") + + return 0 + + +def main(argv: list[str] | None = None) -> int: + """Main CLI entry point. + + Args: + argv: Command line arguments. + + Returns: + Exit code. + """ + parser = argparse.ArgumentParser( + description="Manage IP range groups for Cloudflare", + prog="cloudflare-ip-groups", + ) + parser.add_argument( + "-c", + "--config", + type=Path, + default=Path("ip_groups.yaml"), + help="Path to config file (default: ip_groups.yaml)", + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Enable verbose output", + ) + + subparsers = parser.add_subparsers(dest="command", required=True) + + # Sync command + sync_parser = subparsers.add_parser("sync", help="Sync IP groups to Cloudflare") + sync_parser.add_argument( + "-g", + "--group", + help="Specific group to sync (default: all enabled groups)", + ) + sync_parser.add_argument( + "--dry-run", + action="store_true", + help="Preview changes without applying", + ) + sync_parser.set_defaults(func=cmd_sync) + + # Preview command + preview_parser = subparsers.add_parser( + "preview", help="Preview changes for a group" + ) + preview_parser.add_argument("group", help="Group name to preview") + preview_parser.add_argument("--json", action="store_true", help="Output as JSON") + preview_parser.set_defaults(func=cmd_preview) + + # List command + list_parser = subparsers.add_parser("list", help="List configured groups") + list_parser.add_argument("--json", action="store_true", help="Output as JSON") + list_parser.set_defaults(func=cmd_list) + + # Fetch command + fetch_parser = subparsers.add_parser("fetch", help="Fetch IPs for a group") + fetch_parser.add_argument("group", help="Group name to fetch") + fetch_parser.add_argument("--json", action="store_true", help="Output as JSON") + fetch_parser.add_argument( + "--no-cache", + action="store_true", + help="Bypass cache and fetch fresh data", + ) + fetch_parser.set_defaults(func=cmd_fetch) + + args = parser.parse_args(argv) + setup_logging(args.verbose) + + try: + return args.func(args) + except FileNotFoundError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + except Exception as e: + logging.exception("Unexpected error") + print(f"Error: {e}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/cloudflare-api/src/cloudflare_api/ip_groups/config.py b/packages/cloudflare-api/src/cloudflare_api/ip_groups/config.py new file mode 100644 index 0000000..3093594 --- /dev/null +++ b/packages/cloudflare-api/src/cloudflare_api/ip_groups/config.py @@ -0,0 +1,128 @@ +"""Configuration models for IP range groups. + +Defines the schema for IP group configuration files. +""" + +from enum import Enum +from pathlib import Path +from typing import Any + +import yaml +from pydantic import BaseModel, Field, field_validator + + +class SourceType(str, Enum): + """Types of IP sources.""" + + STATIC = "static" # Hardcoded IP list + URL = "url" # Generic URL returning IP list + GITHUB = "github" # GitHub Meta API + GOOGLE_CLOUD = "google_cloud" # Google Cloud IP ranges + AWS = "aws" # AWS IP ranges + AZURE = "azure" # Azure IP ranges + CLOUDFLARE = "cloudflare" # Cloudflare's own IPs + + +class IPSourceConfig(BaseModel): + """Configuration for an IP source. + + Attributes: + type: Type of IP source + ips: Static list of IPs (for static type) + url: URL to fetch IPs from (for url type) + services: Filter by service names (for provider types) + regions: Filter by regions (for provider types) + ip_version: Filter by IP version (4 or 6, or both if None) + """ + + type: SourceType = Field(description="Type of IP source") + ips: list[str] = Field(default_factory=list, description="Static IP list") + url: str | None = Field(default=None, description="URL to fetch IPs") + services: list[str] = Field( + default_factory=list, description="Service filter (e.g., 'actions', 'hooks')" + ) + regions: list[str] = Field( + default_factory=list, description="Region filter (e.g., 'us-east1')" + ) + ip_version: int | None = Field( + default=None, description="IP version filter (4, 6, or None for both)" + ) + json_path: str | None = Field( + default=None, description="JSONPath to extract IPs from response" + ) + + @field_validator("ip_version") + @classmethod + def validate_ip_version(cls, v: int | None) -> int | None: + """Validate IP version is 4 or 6.""" + if v is not None and v not in (4, 6): + msg = "ip_version must be 4 or 6" + raise ValueError(msg) + return v + + +class IPGroupConfig(BaseModel): + """Configuration for an IP range group. + + Attributes: + name: Human-readable name for the group + cloudflare_list_name: Name of the Cloudflare list to sync to + description: Optional description + sources: List of IP sources that make up this group + enabled: Whether this group is enabled for syncing + tags: Optional tags for categorization + """ + + name: str = Field(description="Human-readable name") + cloudflare_list_name: str = Field(description="Cloudflare list name to sync to") + description: str | None = Field(default=None, description="Optional description") + sources: list[IPSourceConfig] = Field( + default_factory=list, description="IP sources" + ) + enabled: bool = Field(default=True, description="Whether syncing is enabled") + tags: list[str] = Field(default_factory=list, description="Optional tags") + + +class IPGroupsConfig(BaseModel): + """Root configuration for all IP groups. + + Attributes: + version: Config schema version + groups: List of IP group configurations + defaults: Default settings for all groups + """ + + version: str = Field(default="1.0", description="Config schema version") + groups: list[IPGroupConfig] = Field( + default_factory=list, description="IP group configurations" + ) + cache_ttl_seconds: int = Field( + default=3600, description="How long to cache fetched IPs" + ) + cloudflare_list_prefix: str = Field( + default="", description="Prefix for all Cloudflare list names" + ) + + +def load_config(path: str | Path) -> IPGroupsConfig: + """Load IP groups configuration from a YAML file. + + Args: + path: Path to the YAML configuration file. + + Returns: + Parsed configuration. + + Raises: + FileNotFoundError: If the config file doesn't exist. + ValueError: If the config is invalid. + """ + path = Path(path) + if not path.exists(): + msg = f"Config file not found: {path}" + raise FileNotFoundError(msg) + + with path.open() as f: + data: dict[str, Any] = yaml.safe_load(f) + + return IPGroupsConfig.model_validate(data) diff --git a/packages/cloudflare-api/src/cloudflare_api/ip_groups/fetchers.py b/packages/cloudflare-api/src/cloudflare_api/ip_groups/fetchers.py new file mode 100644 index 0000000..03e8f05 --- /dev/null +++ b/packages/cloudflare-api/src/cloudflare_api/ip_groups/fetchers.py @@ -0,0 +1,526 @@ +"""IP fetchers for various sources. + +Fetchers retrieve IP ranges from static lists, URLs, or cloud provider APIs. +""" + +import ipaddress +import json +import logging +from abc import ABC, abstractmethod +from typing import Any + +import httpx + +from cloudflare_api.ip_groups.config import IPSourceConfig, SourceType + +logger = logging.getLogger(__name__) + +# Known provider URLs +GITHUB_META_URL = "https://api.github.com/meta" +GOOGLE_CLOUD_URL = "https://www.gstatic.com/ipranges/cloud.json" +AWS_IP_RANGES_URL = "https://ip-ranges.amazonaws.com/ip-ranges.json" +AZURE_IP_RANGES_URL = "https://www.microsoft.com/en-us/download/details.aspx?id=56519" +CLOUDFLARE_IPV4_URL = "https://www.cloudflare.com/ips-v4" +CLOUDFLARE_IPV6_URL = "https://www.cloudflare.com/ips-v6" + + +class IPFetcher(ABC): + """Base class for IP fetchers.""" + + @abstractmethod + def fetch(self, config: IPSourceConfig) -> list[str]: + """Fetch IP ranges from the source. + + Args: + config: Source configuration. + + Returns: + List of IP addresses or CIDR ranges. + """ + + @staticmethod + def validate_ip(ip: str) -> bool: + """Validate an IP address or CIDR range. + + Args: + ip: IP address or CIDR to validate. + + Returns: + True if valid, False otherwise. + """ + try: + # Try as network (CIDR) + ipaddress.ip_network(ip, strict=False) + return True + except ValueError: + try: + # Try as single address + ipaddress.ip_address(ip) + return True + except ValueError: + return False + + @staticmethod + def get_ip_version(ip: str) -> int: + """Get the IP version (4 or 6) of an address. + + Args: + ip: IP address or CIDR. + + Returns: + 4 or 6. + """ + try: + network = ipaddress.ip_network(ip, strict=False) + return network.version + except ValueError: + address = ipaddress.ip_address(ip.split("/")[0]) + return address.version + + def filter_by_version(self, ips: list[str], version: int | None) -> list[str]: + """Filter IPs by version. + + Args: + ips: List of IP addresses. + version: IP version to filter by (4, 6, or None for all). + + Returns: + Filtered list of IPs. + """ + if version is None: + return ips + return [ip for ip in ips if self.get_ip_version(ip) == version] + + +class StaticIPFetcher(IPFetcher): + """Fetcher for static IP lists.""" + + def fetch(self, config: IPSourceConfig) -> list[str]: + """Return the static IP list from config. + + Args: + config: Source configuration with static IPs. + + Returns: + List of validated IP addresses. + """ + valid_ips = [] + for ip in config.ips: + if self.validate_ip(ip): + valid_ips.append(ip) + else: + logger.warning("Invalid IP address: %s", ip) + + return self.filter_by_version(valid_ips, config.ip_version) + + +class URLIPFetcher(IPFetcher): + """Fetcher for generic URL sources.""" + + def __init__(self, timeout: float = 30.0) -> None: + """Initialize the URL fetcher. + + Args: + timeout: HTTP request timeout in seconds. + """ + self.timeout = timeout + + def fetch(self, config: IPSourceConfig) -> list[str]: + """Fetch IPs from a URL. + + Args: + config: Source configuration with URL. + + Returns: + List of IP addresses extracted from the response. + + Raises: + ValueError: If URL is not configured. + httpx.HTTPError: If the request fails. + """ + if not config.url: + msg = "URL is required for URL source type" + raise ValueError(msg) + + with httpx.Client(timeout=self.timeout) as client: + response = client.get(config.url) + response.raise_for_status() + + content_type = response.headers.get("content-type", "") + + if "json" in content_type: + return self._parse_json(response.text, config) + return self._parse_text(response.text, config) + + def _parse_json(self, text: str, config: IPSourceConfig) -> list[str]: + """Parse JSON response for IPs. + + Args: + text: JSON response text. + config: Source configuration. + + Returns: + List of IP addresses. + """ + data = json.loads(text) + + if config.json_path: + # Simple JSONPath-like extraction + ips = self._extract_json_path(data, config.json_path) + else: + # Try to auto-detect IP fields + ips = self._auto_extract_ips(data) + + valid_ips = [ip for ip in ips if self.validate_ip(ip)] + return self.filter_by_version(valid_ips, config.ip_version) + + def _parse_text(self, text: str, config: IPSourceConfig) -> list[str]: + """Parse plain text response for IPs. + + Args: + text: Plain text response. + config: Source configuration. + + Returns: + List of IP addresses (one per line). + """ + ips = [] + for line in text.strip().split("\n"): + line = line.strip() + if line and not line.startswith("#"): + if self.validate_ip(line): + ips.append(line) + + return self.filter_by_version(ips, config.ip_version) + + def _extract_json_path(self, data: Any, path: str) -> list[str]: + """Extract values from JSON using a simple path. + + Supports paths like "prefixes[*].ip_prefix" or "hooks". + + Args: + data: Parsed JSON data. + path: JSONPath-like expression. + + Returns: + List of extracted string values. + """ + parts = path.split(".") + current = data + + for part in parts: + if "[*]" in part: + # Array access + key = part.replace("[*]", "") + if key: + current = current.get(key, []) + if isinstance(current, list): + # Continue with remaining path on each element + remaining = ".".join(parts[parts.index(part) + 1 :]) + if remaining: + results = [] + for item in current: + results.extend(self._extract_json_path(item, remaining)) + return results + return [str(item) for item in current if item] + elif isinstance(current, dict): + current = current.get(part, {}) + else: + return [] + + if isinstance(current, list): + return [str(item) for item in current if item] + if current: + return [str(current)] + return [] + + def _auto_extract_ips( + self, data: Any, results: list[str] | None = None + ) -> list[str]: + """Auto-extract IP-like values from JSON. + + Args: + data: Parsed JSON data. + results: Accumulator for results. + + Returns: + List of IP-like strings found. + """ + if results is None: + results = [] + + if isinstance(data, dict): + for key, value in data.items(): + # Check if key suggests IP content + if any( + hint in key.lower() for hint in ["ip", "cidr", "prefix", "range"] + ): + if isinstance(value, str) and self.validate_ip(value): + results.append(value) + elif isinstance(value, list): + for item in value: + if isinstance(item, str) and self.validate_ip(item): + results.append(item) + else: + self._auto_extract_ips(value, results) + elif isinstance(data, list): + for item in data: + self._auto_extract_ips(item, results) + + return results + + +class GitHubIPFetcher(IPFetcher): + """Fetcher for GitHub Meta API IP ranges.""" + + def __init__(self, timeout: float = 30.0) -> None: + """Initialize the GitHub fetcher. + + Args: + timeout: HTTP request timeout in seconds. + """ + self.timeout = timeout + + def fetch(self, config: IPSourceConfig) -> list[str]: + """Fetch GitHub IP ranges. + + Args: + config: Source configuration with optional service filters. + + Returns: + List of GitHub IP ranges. + + Available services: hooks, web, api, git, github_enterprise_importer, + packages, pages, importer, actions, actions_macos, dependabot, copilot + """ + with httpx.Client(timeout=self.timeout) as client: + response = client.get(GITHUB_META_URL) + response.raise_for_status() + + data = response.json() + ips: list[str] = [] + + # GitHub services that contain IP ranges + services = ( + config.services + if config.services + else ["hooks", "web", "api", "git", "actions", "dependabot"] + ) + + for service in services: + if service in data: + service_ips = data[service] + if isinstance(service_ips, list): + for ip in service_ips: + if self.validate_ip(ip): + ips.append(ip) + + # Deduplicate + ips = list(set(ips)) + logger.info("Fetched %d IPs from GitHub (%s)", len(ips), ", ".join(services)) + + return self.filter_by_version(ips, config.ip_version) + + +class GoogleCloudIPFetcher(IPFetcher): + """Fetcher for Google Cloud IP ranges.""" + + def __init__(self, timeout: float = 30.0) -> None: + """Initialize the Google Cloud fetcher. + + Args: + timeout: HTTP request timeout in seconds. + """ + self.timeout = timeout + + def fetch(self, config: IPSourceConfig) -> list[str]: + """Fetch Google Cloud IP ranges. + + Args: + config: Source configuration with optional region/service filters. + + Returns: + List of Google Cloud IP ranges. + """ + with httpx.Client(timeout=self.timeout) as client: + response = client.get(GOOGLE_CLOUD_URL) + response.raise_for_status() + + data = response.json() + ips: list[str] = [] + + for prefix in data.get("prefixes", []): + # Check region filter + if config.regions: + scope = prefix.get("scope", "") + if not any(region in scope for region in config.regions): + continue + + # Check service filter + if config.services: + service = prefix.get("service", "") + if service not in config.services: + continue + + # Extract IP prefix + ipv4 = prefix.get("ipv4Prefix") + ipv6 = prefix.get("ipv6Prefix") + + if ipv4 and self.validate_ip(ipv4): + ips.append(ipv4) + if ipv6 and self.validate_ip(ipv6): + ips.append(ipv6) + + ips = list(set(ips)) + logger.info("Fetched %d IPs from Google Cloud", len(ips)) + + return self.filter_by_version(ips, config.ip_version) + + +class AWSIPFetcher(IPFetcher): + """Fetcher for AWS IP ranges.""" + + def __init__(self, timeout: float = 30.0) -> None: + """Initialize the AWS fetcher. + + Args: + timeout: HTTP request timeout in seconds. + """ + self.timeout = timeout + + def fetch(self, config: IPSourceConfig) -> list[str]: + """Fetch AWS IP ranges. + + Args: + config: Source configuration with optional region/service filters. + + Returns: + List of AWS IP ranges. + + Available services: AMAZON, EC2, S3, CLOUDFRONT, ROUTE53, + ROUTE53_HEALTHCHECKS, API_GATEWAY, etc. + """ + with httpx.Client(timeout=self.timeout) as client: + response = client.get(AWS_IP_RANGES_URL) + response.raise_for_status() + + data = response.json() + ips: list[str] = [] + + # Process IPv4 prefixes + for prefix in data.get("prefixes", []): + if not self._matches_filters(prefix, config): + continue + + ip = prefix.get("ip_prefix") + if ip and self.validate_ip(ip): + ips.append(ip) + + # Process IPv6 prefixes + for prefix in data.get("ipv6_prefixes", []): + if not self._matches_filters(prefix, config): + continue + + ip = prefix.get("ipv6_prefix") + if ip and self.validate_ip(ip): + ips.append(ip) + + ips = list(set(ips)) + logger.info("Fetched %d IPs from AWS", len(ips)) + + return self.filter_by_version(ips, config.ip_version) + + def _matches_filters(self, prefix: dict[str, Any], config: IPSourceConfig) -> bool: + """Check if a prefix matches the configured filters. + + Args: + prefix: AWS prefix object. + config: Source configuration. + + Returns: + True if prefix matches filters. + """ + # Check region filter + if config.regions: + region = prefix.get("region", "") + if not any(r in region for r in config.regions): + return False + + # Check service filter + if config.services: + service = prefix.get("service", "") + if service not in config.services: + return False + + return True + + +class CloudflareIPFetcher(IPFetcher): + """Fetcher for Cloudflare's own IP ranges.""" + + def __init__(self, timeout: float = 30.0) -> None: + """Initialize the Cloudflare fetcher. + + Args: + timeout: HTTP request timeout in seconds. + """ + self.timeout = timeout + + def fetch(self, config: IPSourceConfig) -> list[str]: + """Fetch Cloudflare IP ranges. + + Args: + config: Source configuration. + + Returns: + List of Cloudflare IP ranges. + """ + ips: list[str] = [] + + with httpx.Client(timeout=self.timeout) as client: + # Fetch based on IP version filter + if config.ip_version is None or config.ip_version == 4: + response = client.get(CLOUDFLARE_IPV4_URL) + response.raise_for_status() + for line in response.text.strip().split("\n"): + if self.validate_ip(line.strip()): + ips.append(line.strip()) + + if config.ip_version is None or config.ip_version == 6: + response = client.get(CLOUDFLARE_IPV6_URL) + response.raise_for_status() + for line in response.text.strip().split("\n"): + if self.validate_ip(line.strip()): + ips.append(line.strip()) + + logger.info("Fetched %d IPs from Cloudflare", len(ips)) + return ips + + +def get_fetcher(source_type: SourceType) -> IPFetcher: + """Get the appropriate fetcher for a source type. + + Args: + source_type: Type of IP source. + + Returns: + Appropriate fetcher instance. + + Raises: + ValueError: If source type is not supported. + """ + fetchers: dict[SourceType, type[IPFetcher]] = { + SourceType.STATIC: StaticIPFetcher, + SourceType.URL: URLIPFetcher, + SourceType.GITHUB: GitHubIPFetcher, + SourceType.GOOGLE_CLOUD: GoogleCloudIPFetcher, + SourceType.AWS: AWSIPFetcher, + SourceType.CLOUDFLARE: CloudflareIPFetcher, + } + + fetcher_class = fetchers.get(source_type) + if fetcher_class is None: + msg = f"Unsupported source type: {source_type}" + raise ValueError(msg) + + return fetcher_class() diff --git a/packages/cloudflare-api/src/cloudflare_api/ip_groups/manager.py b/packages/cloudflare-api/src/cloudflare_api/ip_groups/manager.py new file mode 100644 index 0000000..934f3a2 --- /dev/null +++ b/packages/cloudflare-api/src/cloudflare_api/ip_groups/manager.py @@ -0,0 +1,460 @@ +"""IP Group Manager for syncing IP ranges to Cloudflare. + +Orchestrates fetching IPs from various sources and syncing to Cloudflare lists. +""" + +import hashlib +import json +import logging +import time +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +from cloudflare_api.client import CloudflareAPIClient +from cloudflare_api.ip_groups.config import ( + IPGroupConfig, + IPGroupsConfig, + IPSourceConfig, + load_config, +) +from cloudflare_api.ip_groups.fetchers import get_fetcher + +logger = logging.getLogger(__name__) + + +@dataclass +class SyncResult: + """Result of syncing an IP group. + + Attributes: + group_name: Name of the IP group + cloudflare_list_name: Cloudflare list name + cloudflare_list_id: Cloudflare list ID + ips_count: Number of IPs synced + added: Number of IPs added + removed: Number of IPs removed + unchanged: Whether the list was unchanged + error: Error message if sync failed + duration_seconds: Time taken to sync + """ + + group_name: str + cloudflare_list_name: str + cloudflare_list_id: str | None = None + ips_count: int = 0 + added: int = 0 + removed: int = 0 + unchanged: bool = False + error: str | None = None + duration_seconds: float = 0.0 + + +@dataclass +class IPCache: + """Cache for fetched IP ranges. + + Attributes: + ips: Cached IP addresses + fetched_at: When the IPs were fetched + source_hash: Hash of the source config for invalidation + """ + + ips: list[str] = field(default_factory=list) + fetched_at: datetime = field(default_factory=datetime.now) + source_hash: str = "" + + +class IPGroupManager: + """Manager for IP range groups. + + Handles fetching IPs from various sources and syncing them to Cloudflare. + + Example: + ```python + manager = IPGroupManager.from_config("ip_groups.yaml") + + # Sync all groups + results = manager.sync_all() + + # Sync a specific group + result = manager.sync_group("github-actions") + + # Preview changes without applying + preview = manager.preview_group("home-network") + ``` + """ + + def __init__( + self, + config: IPGroupsConfig, + client: CloudflareAPIClient | None = None, + ) -> None: + """Initialize the IP Group Manager. + + Args: + config: IP groups configuration. + client: Optional Cloudflare client. If not provided, creates one. + """ + self.config = config + self._client = client + self._cache: dict[str, IPCache] = {} + + @classmethod + def from_config( + cls, + config_path: str | Path, + client: CloudflareAPIClient | None = None, + ) -> "IPGroupManager": + """Create a manager from a config file. + + Args: + config_path: Path to the YAML config file. + client: Optional Cloudflare client. + + Returns: + Configured IPGroupManager. + """ + config = load_config(config_path) + return cls(config, client) + + @property + def client(self) -> CloudflareAPIClient: + """Get or create the Cloudflare client.""" + if self._client is None: + self._client = CloudflareAPIClient() + return self._client + + def _get_source_hash(self, source: IPSourceConfig) -> str: + """Get a hash of the source config for cache invalidation. + + Args: + source: Source configuration. + + Returns: + Hash string. + """ + config_str = json.dumps(source.model_dump(), sort_keys=True) + # MD5 used only for cache key generation, not security purposes + return hashlib.md5(config_str.encode(), usedforsecurity=False).hexdigest()[:8] + + def _is_cache_valid(self, cache: IPCache, source: IPSourceConfig) -> bool: + """Check if cached IPs are still valid. + + Args: + cache: Cached data. + source: Source configuration. + + Returns: + True if cache is valid. + """ + # Check if source config changed + if cache.source_hash != self._get_source_hash(source): + return False + + # Check TTL + ttl = timedelta(seconds=self.config.cache_ttl_seconds) + return datetime.now(tz=timezone.utc) - cache.fetched_at <= ttl + + def fetch_source_ips( + self, + source: IPSourceConfig, + use_cache: bool = True, + ) -> list[str]: + """Fetch IPs from a single source. + + Args: + source: Source configuration. + use_cache: Whether to use cached results. + + Returns: + List of IP addresses. + """ + cache_key = self._get_source_hash(source) + + # Check cache + if use_cache and cache_key in self._cache: + cache = self._cache[cache_key] + if self._is_cache_valid(cache, source): + logger.debug("Using cached IPs for %s", source.type.value) + return cache.ips + + # Fetch from source + fetcher = get_fetcher(source.type) + ips = fetcher.fetch(source) + + # Update cache + self._cache[cache_key] = IPCache( + ips=ips, + fetched_at=datetime.now(tz=timezone.utc), + source_hash=self._get_source_hash(source), + ) + + return ips + + def fetch_group_ips( + self, + group: IPGroupConfig, + use_cache: bool = True, + ) -> list[str]: + """Fetch all IPs for a group from all sources. + + Args: + group: Group configuration. + use_cache: Whether to use cached results. + + Returns: + Deduplicated list of IP addresses. + """ + all_ips: set[str] = set() + + for source in group.sources: + try: + ips = self.fetch_source_ips(source, use_cache) + all_ips.update(ips) + except Exception: + logger.exception( + "Failed to fetch IPs from %s source", + source.type.value, + ) + raise + + logger.info( + "Fetched %d unique IPs for group '%s' from %d sources", + len(all_ips), + group.name, + len(group.sources), + ) + + return sorted(all_ips) + + def preview_group(self, group_name: str) -> dict[str, Any]: + """Preview what would change for a group without applying. + + Args: + group_name: Name of the group to preview. + + Returns: + Dict with current and new IPs, and diff. + + Raises: + ValueError: If group not found. + """ + group = self._get_group(group_name) + + # Fetch new IPs + new_ips = set(self.fetch_group_ips(group)) + + # Get current IPs from Cloudflare + list_name = self._get_cloudflare_list_name(group) + current_ips: set[str] = set() + + existing_list = self.client.get_ip_list_by_name(list_name) + if existing_list: + items = self.client.get_ip_list_items(existing_list.id) + current_ips = {item.ip for item in items} + + # Calculate diff + to_add = new_ips - current_ips + to_remove = current_ips - new_ips + unchanged = current_ips & new_ips + + return { + "group_name": group.name, + "cloudflare_list_name": list_name, + "current_count": len(current_ips), + "new_count": len(new_ips), + "to_add": sorted(to_add), + "to_remove": sorted(to_remove), + "unchanged_count": len(unchanged), + "will_change": bool(to_add or to_remove), + } + + def sync_group(self, group_name: str, dry_run: bool = False) -> SyncResult: + """Sync a single IP group to Cloudflare. + + Args: + group_name: Name of the group to sync. + dry_run: If True, preview without applying changes. + + Returns: + SyncResult with details of the operation. + + Raises: + ValueError: If group not found or disabled. + """ + start_time = time.time() + group = self._get_group(group_name) + + if not group.enabled: + return SyncResult( + group_name=group.name, + cloudflare_list_name=self._get_cloudflare_list_name(group), + error="Group is disabled", + ) + + list_name = self._get_cloudflare_list_name(group) + + try: + # Fetch new IPs + new_ips = self.fetch_group_ips(group) + + if dry_run: + preview = self.preview_group(group_name) + return SyncResult( + group_name=group.name, + cloudflare_list_name=list_name, + ips_count=len(new_ips), + added=len(preview["to_add"]), + removed=len(preview["to_remove"]), + unchanged=not preview["will_change"], + duration_seconds=time.time() - start_time, + ) + + # Ensure list exists + cf_list = self.client.ensure_ip_list( + name=list_name, + kind="ip", + description=group.description or f"Managed IP group: {group.name}", + ) + + # Get current items for diff calculation + current_items = self.client.get_ip_list_items(cf_list.id) + current_ips = {item.ip for item in current_items} + new_ip_set = set(new_ips) + + added = len(new_ip_set - current_ips) + removed = len(current_ips - new_ip_set) + + if added == 0 and removed == 0: + logger.info("No changes needed for '%s'", group.name) + return SyncResult( + group_name=group.name, + cloudflare_list_name=list_name, + cloudflare_list_id=cf_list.id, + ips_count=len(new_ips), + unchanged=True, + duration_seconds=time.time() - start_time, + ) + + # Sync the list + comments = dict.fromkeys(new_ips, f"Managed by {group.name}") + self.client.sync_ip_list(cf_list.id, new_ips, comments) + + logger.info( + "Synced '%s' to Cloudflare: %d IPs (+%d, -%d)", + group.name, + len(new_ips), + added, + removed, + ) + + return SyncResult( + group_name=group.name, + cloudflare_list_name=list_name, + cloudflare_list_id=cf_list.id, + ips_count=len(new_ips), + added=added, + removed=removed, + duration_seconds=time.time() - start_time, + ) + + except Exception: + logger.exception("Failed to sync group '%s'", group.name) + return SyncResult( + group_name=group.name, + cloudflare_list_name=list_name, + error="Sync failed - see logs for details", + duration_seconds=time.time() - start_time, + ) + + def sync_all(self, dry_run: bool = False) -> list[SyncResult]: + """Sync all enabled IP groups to Cloudflare. + + Args: + dry_run: If True, preview without applying changes. + + Returns: + List of SyncResults for each group. + """ + results = [] + + for group in self.config.groups: + if group.enabled: + result = self.sync_group(group.name, dry_run=dry_run) + results.append(result) + else: + logger.debug("Skipping disabled group: %s", group.name) + + # Log summary + success = sum(1 for r in results if r.error is None) + failed = sum(1 for r in results if r.error is not None) + total_ips = sum(r.ips_count for r in results if r.error is None) + + logger.info( + "Sync complete: %d groups succeeded, %d failed, %d total IPs", + success, + failed, + total_ips, + ) + + return results + + def list_groups(self) -> list[dict[str, Any]]: + """List all configured IP groups. + + Returns: + List of group summaries. + """ + return [ + { + "name": group.name, + "cloudflare_list_name": self._get_cloudflare_list_name(group), + "description": group.description, + "enabled": group.enabled, + "sources_count": len(group.sources), + "source_types": [s.type.value for s in group.sources], + "tags": group.tags, + } + for group in self.config.groups + ] + + def _get_group(self, group_name: str) -> IPGroupConfig: + """Get a group by name. + + Args: + group_name: Name of the group. + + Returns: + Group configuration. + + Raises: + ValueError: If group not found. + """ + for group in self.config.groups: + if group.name == group_name: + return group + + available = [g.name for g in self.config.groups] + msg = f"Group '{group_name}' not found. Available: {available}" + raise ValueError(msg) + + def _get_cloudflare_list_name(self, group: IPGroupConfig) -> str: + """Get the Cloudflare list name for a group. + + Args: + group: Group configuration. + + Returns: + Cloudflare list name with optional prefix. + """ + prefix = self.config.cloudflare_list_prefix + if prefix: + return f"{prefix}{group.cloudflare_list_name}" + return group.cloudflare_list_name + + def clear_cache(self) -> None: + """Clear all cached IP data.""" + self._cache.clear() + logger.info("Cleared IP cache") diff --git a/packages/cloudflare-api/src/cloudflare_api/models.py b/packages/cloudflare-api/src/cloudflare_api/models.py new file mode 100644 index 0000000..abf928c --- /dev/null +++ b/packages/cloudflare-api/src/cloudflare_api/models.py @@ -0,0 +1,123 @@ +"""Pydantic models for Cloudflare API responses. + +Type-safe models for IP lists, items, and bulk operations. +""" + +from datetime import datetime +from enum import Enum +from typing import Any + +from pydantic import BaseModel, Field + + +class ListKind(str, Enum): + """Types of Cloudflare lists.""" + + IP = "ip" + REDIRECT = "redirect" + HOSTNAME = "hostname" + ASN = "asn" + + +class BulkOperationStatus(str, Enum): + """Status of a bulk operation.""" + + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + + +class IPListItem(BaseModel): + """An item in an IP list. + + Attributes: + id: Unique identifier for the item + ip: IP address or CIDR range + comment: Optional description + created_on: When the item was created + modified_on: When the item was last modified + """ + + id: str | None = None + ip: str = Field(description="IP address or CIDR range") + comment: str | None = Field(default=None, description="Optional description") + created_on: datetime | None = None + modified_on: datetime | None = None + + +class IPList(BaseModel): + """A Cloudflare IP list. + + Attributes: + id: Unique identifier for the list + name: List name (must be unique per account) + description: Optional description + kind: Type of list (ip, redirect, hostname, asn) + num_items: Number of items in the list + num_referencing_filters: Number of firewall filters using this list + created_on: When the list was created + modified_on: When the list was last modified + """ + + id: str + name: str + description: str | None = None + kind: ListKind = ListKind.IP + num_items: int = 0 + num_referencing_filters: int = 0 + created_on: datetime | None = None + modified_on: datetime | None = None + + +class BulkOperation(BaseModel): + """Status of a bulk operation. + + Attributes: + id: Operation identifier + status: Current status + error: Error message if failed + completed: When the operation completed + """ + + id: str + status: BulkOperationStatus + error: str | None = None + completed: datetime | None = None + + +class IPListItemInput(BaseModel): + """Input model for creating/updating IP list items. + + Attributes: + ip: IP address or CIDR range + comment: Optional description + """ + + ip: str = Field(description="IP address or CIDR range") + comment: str | None = Field(default=None, description="Optional description") + + def to_api_dict(self) -> dict[str, Any]: + """Convert to API request format. + + Returns: + Dictionary for API request. + """ + result: dict[str, Any] = {"ip": self.ip} + if self.comment: + result["comment"] = self.comment + return result + + +class CreateIPListRequest(BaseModel): + """Request to create a new IP list. + + Attributes: + name: List name (must be unique per account) + kind: Type of list + description: Optional description + """ + + name: str = Field(description="List name (must be unique)") + kind: ListKind = Field(default=ListKind.IP, description="Type of list") + description: str | None = Field(default=None, description="Optional description") diff --git a/packages/cloudflare-api/src/cloudflare_api/settings.py b/packages/cloudflare-api/src/cloudflare_api/settings.py new file mode 100644 index 0000000..d686248 --- /dev/null +++ b/packages/cloudflare-api/src/cloudflare_api/settings.py @@ -0,0 +1,133 @@ +"""Cloudflare API configuration settings. + +Environment-based configuration for Cloudflare API authentication and defaults. +""" + +from pydantic import Field, SecretStr, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class CloudflareAPISettings(BaseSettings): + """Configuration for Cloudflare API client. + + All settings can be configured via environment variables or .env file. + + Attributes: + cloudflare_api_token: API token with appropriate permissions + cloudflare_account_id: Cloudflare account identifier + cloudflare_api_email: Optional email for legacy API key auth + cloudflare_api_key: Optional global API key (legacy) + default_list_kind: Default kind for new IP lists (ip, redirect, hostname, asn) + request_timeout: HTTP request timeout in seconds + max_retries: Maximum number of retries for failed requests + """ + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + case_sensitive=False, + populate_by_name=True, + ) + + # Required authentication + cloudflare_api_token: SecretStr = Field( + ..., + alias="CLOUDFLARE_API_TOKEN", + description="Cloudflare API token with required permissions", + ) + cloudflare_account_id: str = Field( + ..., + alias="CLOUDFLARE_ACCOUNT_ID", + description="Cloudflare account identifier", + ) + + # Optional legacy authentication (prefer API token) + cloudflare_api_email: str | None = Field( + default=None, + alias="CLOUDFLARE_API_EMAIL", + description="Email for legacy API key authentication", + ) + cloudflare_api_key: SecretStr | None = Field( + default=None, + alias="CLOUDFLARE_API_KEY", + description="Global API key (legacy, prefer API token)", + ) + + # Optional zone-level operations + cloudflare_zone_id: str | None = Field( + default=None, + alias="CLOUDFLARE_ZONE_ID", + description="Default zone ID for zone-scoped operations", + ) + + # Client configuration + default_list_kind: str = Field( + default="ip", + alias="CF_DEFAULT_LIST_KIND", + description="Default kind for new lists (ip, redirect, hostname, asn)", + ) + request_timeout: int = Field( + default=30, + alias="CF_REQUEST_TIMEOUT", + description="HTTP request timeout in seconds", + ) + max_retries: int = Field( + default=3, + alias="CF_MAX_RETRIES", + description="Maximum retries for failed requests", + ) + + # Bulk operation settings + bulk_operation_poll_interval: float = Field( + default=1.0, + alias="CF_BULK_POLL_INTERVAL", + description="Seconds between bulk operation status checks", + ) + bulk_operation_timeout: int = Field( + default=300, + alias="CF_BULK_TIMEOUT", + description="Maximum seconds to wait for bulk operations", + ) + + @field_validator("default_list_kind") + @classmethod + def validate_list_kind(cls, v: str) -> str: + """Validate list kind is a supported type.""" + valid_kinds = {"ip", "redirect", "hostname", "asn"} + if v.lower() not in valid_kinds: + msg = f"Invalid list kind: {v}. Must be one of: {', '.join(valid_kinds)}" + raise ValueError(msg) + return v.lower() + + def get_token_value(self) -> str: + """Get the API token as a plain string. + + Returns: + The API token value. + """ + return self.cloudflare_api_token.get_secret_value() + + +_settings_instance: CloudflareAPISettings | None = None + + +def get_cloudflare_api_settings() -> CloudflareAPISettings: + """Get default settings (singleton, reads from environment). + + Returns: + CloudflareAPISettings instance. + + Raises: + ValidationError: If required environment variables are missing. + """ + global _settings_instance + if _settings_instance is None: + _settings_instance = CloudflareAPISettings() + return _settings_instance + + +def reset_settings() -> None: + """Reset singleton (for testing).""" + global _settings_instance + _settings_instance = None diff --git a/packages/cloudflare-api/tests/__init__.py b/packages/cloudflare-api/tests/__init__.py new file mode 100644 index 0000000..2e065b9 --- /dev/null +++ b/packages/cloudflare-api/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for cloudflare-api package.""" diff --git a/packages/cloudflare-api/tests/conftest.py b/packages/cloudflare-api/tests/conftest.py new file mode 100644 index 0000000..833d08d --- /dev/null +++ b/packages/cloudflare-api/tests/conftest.py @@ -0,0 +1,88 @@ +"""Pytest configuration for cloudflare-api tests.""" + +import os +from unittest.mock import MagicMock, patch + +import pytest +from cloudflare_api.settings import reset_settings + +# Test constants +TEST_ACCOUNT_ID = "test-account-id-12345" +TEST_API_TOKEN = "test-api-token-secret" +TEST_LIST_ID = "test-list-id-67890" +TEST_OPERATION_ID = "test-operation-id-abcde" + + +@pytest.fixture(autouse=True) +def reset_settings_after_test(): + """Reset settings singleton after each test.""" + yield + reset_settings() + + +@pytest.fixture +def mock_env_vars(): + """Set required environment variables for testing.""" + with patch.dict( + os.environ, + { + "CLOUDFLARE_API_TOKEN": TEST_API_TOKEN, + "CLOUDFLARE_ACCOUNT_ID": TEST_ACCOUNT_ID, + }, + ): + yield + + +@pytest.fixture +def mock_cloudflare_client(): + """Create a mock Cloudflare client.""" + with patch("cloudflare_api.client.Cloudflare") as mock_cf: + mock_instance = MagicMock() + mock_cf.return_value = mock_instance + yield mock_instance + + +@pytest.fixture +def sample_ip_list_response(): + """Sample IP list response from Cloudflare API.""" + mock_list = MagicMock() + mock_list.id = TEST_LIST_ID + mock_list.name = "test-list" + mock_list.description = "Test IP list" + mock_list.kind = "ip" + mock_list.num_items = 5 + mock_list.num_referencing_filters = 0 + mock_list.created_on = None + mock_list.modified_on = None + return mock_list + + +@pytest.fixture +def sample_ip_list_item_response(): + """Sample IP list item response.""" + mock_item = MagicMock() + mock_item.id = "item-id-123" + mock_item.ip = "192.168.1.1" + mock_item.comment = "Test IP" + mock_item.created_on = None + mock_item.modified_on = None + return mock_item + + +@pytest.fixture +def sample_bulk_operation_response(): + """Sample bulk operation response.""" + mock_op = MagicMock() + mock_op.operation_id = TEST_OPERATION_ID + return mock_op + + +@pytest.fixture +def sample_bulk_operation_status(): + """Sample bulk operation status response.""" + mock_status = MagicMock() + mock_status.id = TEST_OPERATION_ID + mock_status.status = "completed" + mock_status.error = None + mock_status.completed = None + return mock_status diff --git a/packages/cloudflare-api/tests/test_client.py b/packages/cloudflare-api/tests/test_client.py new file mode 100644 index 0000000..3b20a82 --- /dev/null +++ b/packages/cloudflare-api/tests/test_client.py @@ -0,0 +1,394 @@ +"""Tests for cloudflare_api client.""" + +from unittest.mock import MagicMock + +import pytest +from cloudflare._exceptions import AuthenticationError, BadRequestError, NotFoundError +from cloudflare_api.client import CloudflareAPIClient +from cloudflare_api.exceptions import ( + CloudflareAuthError, + CloudflareBulkOperationError, + CloudflareConflictError, + CloudflareNotFoundError, + CloudflareValidationError, +) +from cloudflare_api.models import BulkOperationStatus, ListKind +from cloudflare_api.settings import CloudflareAPISettings + + +class TestCloudflareAPIClientInit: + """Test suite for CloudflareAPIClient initialization.""" + + def test_init_with_settings(self, mock_env_vars, mock_cloudflare_client): + """Test client initialization with explicit settings.""" + settings = CloudflareAPISettings() + client = CloudflareAPIClient(settings=settings) + + assert client.settings == settings + assert client._account_id == "test-account-id-12345" + + def test_init_from_environment(self, mock_env_vars, mock_cloudflare_client): + """Test client initialization from environment variables.""" + client = CloudflareAPIClient() + + assert client._account_id == "test-account-id-12345" + + +class TestIPListOperations: + """Test suite for IP list operations.""" + + @pytest.fixture + def client(self, mock_env_vars, mock_cloudflare_client): + """Create a client with mocked Cloudflare SDK.""" + return CloudflareAPIClient() + + def test_list_ip_lists( + self, client, mock_cloudflare_client, sample_ip_list_response + ): + """Test listing IP lists.""" + mock_cloudflare_client.rules.lists.list.return_value = [sample_ip_list_response] + + lists = client.list_ip_lists() + + assert len(lists) == 1 + assert lists[0].id == "test-list-id-67890" + assert lists[0].name == "test-list" + assert lists[0].kind == ListKind.IP + + def test_list_ip_lists_empty(self, client, mock_cloudflare_client): + """Test listing IP lists when none exist.""" + mock_cloudflare_client.rules.lists.list.return_value = [] + + lists = client.list_ip_lists() + + assert lists == [] + + def test_get_ip_list(self, client, mock_cloudflare_client, sample_ip_list_response): + """Test getting a specific IP list.""" + mock_cloudflare_client.rules.lists.get.return_value = sample_ip_list_response + + ip_list = client.get_ip_list("test-list-id-67890") + + assert ip_list.id == "test-list-id-67890" + assert ip_list.name == "test-list" + + def test_get_ip_list_not_found(self, client, mock_cloudflare_client): + """Test getting a non-existent IP list.""" + mock_cloudflare_client.rules.lists.get.side_effect = NotFoundError( + "Not found", response=MagicMock(), body=None + ) + + with pytest.raises(CloudflareNotFoundError): + client.get_ip_list("nonexistent") + + def test_get_ip_list_by_name_found( + self, client, mock_cloudflare_client, sample_ip_list_response + ): + """Test getting IP list by name when it exists.""" + mock_cloudflare_client.rules.lists.list.return_value = [sample_ip_list_response] + + ip_list = client.get_ip_list_by_name("test-list") + + assert ip_list is not None + assert ip_list.name == "test-list" + + def test_get_ip_list_by_name_not_found(self, client, mock_cloudflare_client): + """Test getting IP list by name when it doesn't exist.""" + mock_cloudflare_client.rules.lists.list.return_value = [] + + ip_list = client.get_ip_list_by_name("nonexistent") + + assert ip_list is None + + def test_create_ip_list( + self, client, mock_cloudflare_client, sample_ip_list_response + ): + """Test creating a new IP list.""" + mock_cloudflare_client.rules.lists.create.return_value = sample_ip_list_response + + ip_list = client.create_ip_list( + name="new-list", + kind="ip", + description="New list", + ) + + assert ip_list.id == "test-list-id-67890" + mock_cloudflare_client.rules.lists.create.assert_called_once() + + def test_create_ip_list_already_exists(self, client, mock_cloudflare_client): + """Test creating a list that already exists.""" + mock_cloudflare_client.rules.lists.create.side_effect = BadRequestError( + "List already exists", response=MagicMock(), body=None + ) + + with pytest.raises(CloudflareConflictError): + client.create_ip_list(name="existing-list") + + def test_delete_ip_list(self, client, mock_cloudflare_client): + """Test deleting an IP list.""" + mock_cloudflare_client.rules.lists.delete.return_value = None + + result = client.delete_ip_list("test-list-id") + + assert result is True + mock_cloudflare_client.rules.lists.delete.assert_called_once() + + def test_delete_ip_list_in_use(self, client, mock_cloudflare_client): + """Test deleting a list that's in use.""" + mock_cloudflare_client.rules.lists.delete.side_effect = BadRequestError( + "List is in use by firewall rules", response=MagicMock(), body=None + ) + + with pytest.raises(CloudflareConflictError): + client.delete_ip_list("in-use-list") + + +class TestIPListItemOperations: + """Test suite for IP list item operations.""" + + @pytest.fixture + def client(self, mock_env_vars, mock_cloudflare_client): + """Create a client with mocked Cloudflare SDK.""" + return CloudflareAPIClient() + + def test_get_ip_list_items( + self, client, mock_cloudflare_client, sample_ip_list_item_response + ): + """Test getting items from an IP list.""" + mock_cloudflare_client.rules.lists.items.list.return_value = [ + sample_ip_list_item_response + ] + + items = client.get_ip_list_items("test-list-id") + + assert len(items) == 1 + assert items[0].ip == "192.168.1.1" + assert items[0].comment == "Test IP" + + def test_add_ip_list_items( + self, + client, + mock_cloudflare_client, + sample_bulk_operation_response, + sample_bulk_operation_status, + ): + """Test adding items to an IP list.""" + mock_cloudflare_client.rules.lists.items.create.return_value = ( + sample_bulk_operation_response + ) + mock_cloudflare_client.rules.lists.bulk_operations.get.return_value = ( + sample_bulk_operation_status + ) + + result = client.add_ip_list_items( + list_id="test-list-id", + items=[{"ip": "1.2.3.4", "comment": "New IP"}], + ) + + assert result is None # Waited for completion + mock_cloudflare_client.rules.lists.items.create.assert_called_once() + + def test_add_ip_list_items_no_wait( + self, client, mock_cloudflare_client, sample_bulk_operation_response + ): + """Test adding items without waiting for completion.""" + mock_cloudflare_client.rules.lists.items.create.return_value = ( + sample_bulk_operation_response + ) + + result = client.add_ip_list_items( + list_id="test-list-id", + items=[{"ip": "1.2.3.4"}], + wait_for_completion=False, + ) + + assert result == "test-operation-id-abcde" + + def test_replace_ip_list_items( + self, + client, + mock_cloudflare_client, + sample_bulk_operation_response, + sample_bulk_operation_status, + ): + """Test replacing all items in a list.""" + mock_cloudflare_client.rules.lists.items.update.return_value = ( + sample_bulk_operation_response + ) + mock_cloudflare_client.rules.lists.bulk_operations.get.return_value = ( + sample_bulk_operation_status + ) + + result = client.replace_ip_list_items( + list_id="test-list-id", + items=[{"ip": "5.6.7.8"}], + ) + + assert result is None + mock_cloudflare_client.rules.lists.items.update.assert_called_once() + + def test_delete_ip_list_items( + self, + client, + mock_cloudflare_client, + sample_bulk_operation_response, + sample_bulk_operation_status, + ): + """Test deleting specific items.""" + mock_cloudflare_client.rules.lists.items.delete.return_value = ( + sample_bulk_operation_response + ) + mock_cloudflare_client.rules.lists.bulk_operations.get.return_value = ( + sample_bulk_operation_status + ) + + result = client.delete_ip_list_items( + list_id="test-list-id", + item_ids=["item-1", "item-2"], + ) + + assert result is None + mock_cloudflare_client.rules.lists.items.delete.assert_called_once() + + +class TestBulkOperations: + """Test suite for bulk operation handling.""" + + @pytest.fixture + def client(self, mock_env_vars, mock_cloudflare_client): + """Create a client with mocked Cloudflare SDK.""" + return CloudflareAPIClient() + + def test_get_bulk_operation_status( + self, client, mock_cloudflare_client, sample_bulk_operation_status + ): + """Test getting bulk operation status.""" + mock_cloudflare_client.rules.lists.bulk_operations.get.return_value = ( + sample_bulk_operation_status + ) + + status = client.get_bulk_operation_status("test-op-id") + + assert status.id == "test-operation-id-abcde" + assert status.status == BulkOperationStatus.COMPLETED + + def test_wait_for_bulk_operation_completed( + self, client, mock_cloudflare_client, sample_bulk_operation_status + ): + """Test waiting for a bulk operation that completes.""" + mock_cloudflare_client.rules.lists.bulk_operations.get.return_value = ( + sample_bulk_operation_status + ) + + result = client._wait_for_bulk_operation("test-op-id") + + assert result.status == BulkOperationStatus.COMPLETED + + def test_wait_for_bulk_operation_failed(self, client, mock_cloudflare_client): + """Test waiting for a bulk operation that fails.""" + mock_status = MagicMock() + mock_status.id = "test-op-id" + mock_status.status = "failed" + mock_status.error = "Invalid IP format" + mock_status.completed = None + + mock_cloudflare_client.rules.lists.bulk_operations.get.return_value = ( + mock_status + ) + + with pytest.raises(CloudflareBulkOperationError) as exc_info: + client._wait_for_bulk_operation("test-op-id") + + assert "failed" in str(exc_info.value) + + +class TestConvenienceMethods: + """Test suite for convenience methods.""" + + @pytest.fixture + def client(self, mock_env_vars, mock_cloudflare_client): + """Create a client with mocked Cloudflare SDK.""" + return CloudflareAPIClient() + + def test_ensure_ip_list_exists( + self, client, mock_cloudflare_client, sample_ip_list_response + ): + """Test ensure_ip_list when list already exists.""" + mock_cloudflare_client.rules.lists.list.return_value = [sample_ip_list_response] + + ip_list = client.ensure_ip_list("test-list") + + assert ip_list.id == "test-list-id-67890" + # Should not call create + mock_cloudflare_client.rules.lists.create.assert_not_called() + + def test_ensure_ip_list_creates( + self, client, mock_cloudflare_client, sample_ip_list_response + ): + """Test ensure_ip_list creates new list when not found.""" + mock_cloudflare_client.rules.lists.list.return_value = [] + mock_cloudflare_client.rules.lists.create.return_value = sample_ip_list_response + + ip_list = client.ensure_ip_list("new-list", description="Test") + + assert ip_list.id == "test-list-id-67890" + mock_cloudflare_client.rules.lists.create.assert_called_once() + + def test_sync_ip_list( + self, + client, + mock_cloudflare_client, + sample_bulk_operation_response, + sample_bulk_operation_status, + ): + """Test syncing IP list to specific IPs.""" + mock_cloudflare_client.rules.lists.items.update.return_value = ( + sample_bulk_operation_response + ) + mock_cloudflare_client.rules.lists.bulk_operations.get.return_value = ( + sample_bulk_operation_status + ) + + client.sync_ip_list( + list_id="test-list-id", + ips=["1.2.3.4", "5.6.7.8"], + comments={"1.2.3.4": "First IP"}, + ) + + mock_cloudflare_client.rules.lists.items.update.assert_called_once() + + +class TestErrorHandling: + """Test suite for error handling.""" + + @pytest.fixture + def client(self, mock_env_vars, mock_cloudflare_client): + """Create a client with mocked Cloudflare SDK.""" + return CloudflareAPIClient() + + def test_authentication_error(self, client, mock_cloudflare_client): + """Test handling of authentication errors.""" + mock_cloudflare_client.rules.lists.list.side_effect = AuthenticationError( + "Invalid token", response=MagicMock(), body=None + ) + + with pytest.raises(CloudflareAuthError): + client.list_ip_lists() + + def test_validation_error(self, client, mock_cloudflare_client): + """Test handling of validation errors.""" + mock_cloudflare_client.rules.lists.create.side_effect = BadRequestError( + "Invalid name format", response=MagicMock(), body=None + ) + + with pytest.raises(CloudflareValidationError): + client.create_ip_list(name="invalid@name") + + def test_not_found_error(self, client, mock_cloudflare_client): + """Test handling of not found errors.""" + mock_cloudflare_client.rules.lists.get.side_effect = NotFoundError( + "List not found", response=MagicMock(), body=None + ) + + with pytest.raises(CloudflareNotFoundError): + client.get_ip_list("nonexistent") diff --git a/packages/cloudflare-api/tests/test_ip_groups.py b/packages/cloudflare-api/tests/test_ip_groups.py new file mode 100644 index 0000000..97ed6d6 --- /dev/null +++ b/packages/cloudflare-api/tests/test_ip_groups.py @@ -0,0 +1,488 @@ +"""Tests for IP groups functionality.""" + +import json +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import yaml +from cloudflare_api.ip_groups.config import ( + IPGroupConfig, + IPGroupsConfig, + IPSourceConfig, + SourceType, + load_config, +) +from cloudflare_api.ip_groups.fetchers import ( + AWSIPFetcher, + CloudflareIPFetcher, + GitHubIPFetcher, + GoogleCloudIPFetcher, + IPFetcher, + StaticIPFetcher, + URLIPFetcher, + get_fetcher, +) +from cloudflare_api.ip_groups.manager import IPGroupManager + + +class TestIPSourceConfig: + """Tests for IP source configuration.""" + + def test_static_source(self): + """Test static IP source configuration.""" + config = IPSourceConfig( + type=SourceType.STATIC, + ips=["192.168.1.1", "10.0.0.0/8"], + ) + assert config.type == SourceType.STATIC + assert len(config.ips) == 2 + + def test_github_source(self): + """Test GitHub source configuration.""" + config = IPSourceConfig( + type=SourceType.GITHUB, + services=["actions", "hooks"], + ) + assert config.type == SourceType.GITHUB + assert "actions" in config.services + + def test_url_source(self): + """Test URL source configuration.""" + config = IPSourceConfig( + type=SourceType.URL, + url="https://example.com/ips.txt", + json_path="prefixes[*].ip", + ) + assert config.url == "https://example.com/ips.txt" + assert config.json_path == "prefixes[*].ip" + + def test_ip_version_validation(self): + """Test IP version validation.""" + # Valid versions + config = IPSourceConfig(type=SourceType.STATIC, ip_version=4) + assert config.ip_version == 4 + + config = IPSourceConfig(type=SourceType.STATIC, ip_version=6) + assert config.ip_version == 6 + + # Invalid version + with pytest.raises(ValueError, match="ip_version must be 4 or 6"): + IPSourceConfig(type=SourceType.STATIC, ip_version=5) + + +class TestIPGroupConfig: + """Tests for IP group configuration.""" + + def test_basic_group(self): + """Test basic group configuration.""" + config = IPGroupConfig( + name="test-group", + cloudflare_list_name="test-list", + description="Test group", + sources=[ + IPSourceConfig(type=SourceType.STATIC, ips=["1.2.3.4"]), + ], + ) + assert config.name == "test-group" + assert config.cloudflare_list_name == "test-list" + assert config.enabled is True + assert len(config.sources) == 1 + + def test_disabled_group(self): + """Test disabled group.""" + config = IPGroupConfig( + name="disabled", + cloudflare_list_name="disabled-list", + enabled=False, + sources=[], + ) + assert config.enabled is False + + +class TestLoadConfig: + """Tests for configuration loading.""" + + def test_load_valid_config(self): + """Test loading a valid configuration file.""" + config_data = { + "version": "1.0", + "cache_ttl_seconds": 1800, + "groups": [ + { + "name": "home", + "cloudflare_list_name": "home-ips", + "sources": [ + {"type": "static", "ips": ["192.168.1.1"]}, + ], + }, + ], + } + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump(config_data, f) + f.flush() + + config = load_config(f.name) + + assert config.version == "1.0" + assert config.cache_ttl_seconds == 1800 + assert len(config.groups) == 1 + assert config.groups[0].name == "home" + + Path(f.name).unlink() + + def test_load_missing_file(self): + """Test loading a missing file raises error.""" + with pytest.raises(FileNotFoundError): + load_config("/nonexistent/path.yaml") + + +class TestStaticIPFetcher: + """Tests for static IP fetcher.""" + + def test_fetch_valid_ips(self): + """Test fetching valid static IPs.""" + config = IPSourceConfig( + type=SourceType.STATIC, + ips=["192.168.1.1", "10.0.0.0/8", "2001:db8::1"], + ) + fetcher = StaticIPFetcher() + ips = fetcher.fetch(config) + + assert len(ips) == 3 + assert "192.168.1.1" in ips + assert "10.0.0.0/8" in ips + + def test_fetch_filters_invalid_ips(self): + """Test that invalid IPs are filtered out.""" + config = IPSourceConfig( + type=SourceType.STATIC, + ips=["192.168.1.1", "invalid-ip", "10.0.0.0/8"], + ) + fetcher = StaticIPFetcher() + ips = fetcher.fetch(config) + + assert len(ips) == 2 + assert "invalid-ip" not in ips + + def test_fetch_filters_by_version(self): + """Test filtering by IP version.""" + config = IPSourceConfig( + type=SourceType.STATIC, + ips=["192.168.1.1", "2001:db8::1"], + ip_version=4, + ) + fetcher = StaticIPFetcher() + ips = fetcher.fetch(config) + + assert len(ips) == 1 + assert "192.168.1.1" in ips + + +class TestIPFetcherValidation: + """Tests for IP validation in fetchers.""" + + def test_validate_ipv4(self): + """Test IPv4 validation.""" + assert IPFetcher.validate_ip("192.168.1.1") is True + assert IPFetcher.validate_ip("10.0.0.0/8") is True + assert IPFetcher.validate_ip("0.0.0.0") is True + assert IPFetcher.validate_ip("255.255.255.255") is True + + def test_validate_ipv6(self): + """Test IPv6 validation.""" + assert IPFetcher.validate_ip("2001:db8::1") is True + assert IPFetcher.validate_ip("::1") is True + assert IPFetcher.validate_ip("fe80::/10") is True + + def test_validate_invalid(self): + """Test invalid IP validation.""" + assert IPFetcher.validate_ip("invalid") is False + assert IPFetcher.validate_ip("256.1.1.1") is False + assert IPFetcher.validate_ip("") is False + + def test_get_ip_version(self): + """Test IP version detection.""" + assert IPFetcher.get_ip_version("192.168.1.1") == 4 + assert IPFetcher.get_ip_version("10.0.0.0/8") == 4 + assert IPFetcher.get_ip_version("2001:db8::1") == 6 + assert IPFetcher.get_ip_version("fe80::/10") == 6 + + +class TestURLIPFetcher: + """Tests for URL IP fetcher.""" + + def test_parse_text_response(self): + """Test parsing plain text IP list.""" + fetcher = URLIPFetcher() + config = IPSourceConfig(type=SourceType.URL, url="http://example.com") + + text = """ + # Comment + 192.168.1.1 + 10.0.0.0/8 + + 2001:db8::1 + """ + + ips = fetcher._parse_text(text, config) + assert len(ips) == 3 + + def test_parse_json_with_path(self): + """Test parsing JSON with JSONPath.""" + fetcher = URLIPFetcher() + config = IPSourceConfig( + type=SourceType.URL, + url="http://example.com", + json_path="prefixes[*].cidr", + ) + + json_text = json.dumps( + { + "prefixes": [ + {"cidr": "192.168.1.0/24"}, + {"cidr": "10.0.0.0/8"}, + ] + } + ) + + ips = fetcher._parse_json(json_text, config) + assert len(ips) == 2 + + +class TestGetFetcher: + """Tests for fetcher factory.""" + + def test_get_static_fetcher(self): + """Test getting static fetcher.""" + fetcher = get_fetcher(SourceType.STATIC) + assert isinstance(fetcher, StaticIPFetcher) + + def test_get_url_fetcher(self): + """Test getting URL fetcher.""" + fetcher = get_fetcher(SourceType.URL) + assert isinstance(fetcher, URLIPFetcher) + + def test_get_github_fetcher(self): + """Test getting GitHub fetcher.""" + fetcher = get_fetcher(SourceType.GITHUB) + assert isinstance(fetcher, GitHubIPFetcher) + + def test_get_google_cloud_fetcher(self): + """Test getting Google Cloud fetcher.""" + fetcher = get_fetcher(SourceType.GOOGLE_CLOUD) + assert isinstance(fetcher, GoogleCloudIPFetcher) + + def test_get_aws_fetcher(self): + """Test getting AWS fetcher.""" + fetcher = get_fetcher(SourceType.AWS) + assert isinstance(fetcher, AWSIPFetcher) + + def test_get_cloudflare_fetcher(self): + """Test getting Cloudflare fetcher.""" + fetcher = get_fetcher(SourceType.CLOUDFLARE) + assert isinstance(fetcher, CloudflareIPFetcher) + + +class TestIPGroupManager: + """Tests for IP group manager.""" + + @pytest.fixture + def sample_config(self): + """Create sample configuration.""" + return IPGroupsConfig( + version="1.0", + cache_ttl_seconds=3600, + groups=[ + IPGroupConfig( + name="test-group", + cloudflare_list_name="test-list", + description="Test", + sources=[ + IPSourceConfig( + type=SourceType.STATIC, + ips=["192.168.1.1", "10.0.0.0/8"], + ), + ], + ), + IPGroupConfig( + name="disabled-group", + cloudflare_list_name="disabled-list", + enabled=False, + sources=[], + ), + ], + ) + + @pytest.fixture + def mock_client(self): + """Create mock Cloudflare client.""" + client = MagicMock() + client.get_ip_list_by_name.return_value = None + client.ensure_ip_list.return_value = MagicMock(id="list-123") + client.get_ip_list_items.return_value = [] + return client + + def test_list_groups(self, sample_config): + """Test listing configured groups.""" + manager = IPGroupManager(sample_config) + groups = manager.list_groups() + + assert len(groups) == 2 + assert groups[0]["name"] == "test-group" + assert groups[0]["enabled"] is True + assert groups[1]["enabled"] is False + + def test_fetch_group_ips(self, sample_config): + """Test fetching IPs for a group.""" + manager = IPGroupManager(sample_config) + ips = manager.fetch_group_ips(sample_config.groups[0]) + + assert len(ips) == 2 + assert "192.168.1.1" in ips + assert "10.0.0.0/8" in ips + + def test_get_group_not_found(self, sample_config): + """Test error when group not found.""" + manager = IPGroupManager(sample_config) + + with pytest.raises(ValueError, match="not found"): + manager._get_group("nonexistent") + + def test_sync_disabled_group(self, sample_config, mock_client): + """Test syncing a disabled group.""" + manager = IPGroupManager(sample_config, mock_client) + result = manager.sync_group("disabled-group") + + assert result.error == "Group is disabled" + mock_client.sync_ip_list.assert_not_called() + + def test_cache_invalidation(self, sample_config): + """Test cache invalidation when config changes.""" + manager = IPGroupManager(sample_config) + + source = sample_config.groups[0].sources[0] + hash1 = manager._get_source_hash(source) + + # Change config + source.ips.append("1.2.3.4") + hash2 = manager._get_source_hash(source) + + assert hash1 != hash2 + + def test_cloudflare_list_prefix(self): + """Test Cloudflare list name prefix.""" + config = IPGroupsConfig( + cloudflare_list_prefix="myapp-", + groups=[ + IPGroupConfig( + name="test", + cloudflare_list_name="ips", + sources=[], + ), + ], + ) + manager = IPGroupManager(config) + list_name = manager._get_cloudflare_list_name(config.groups[0]) + + assert list_name == "myapp-ips" + + +class TestGitHubIPFetcher: + """Tests for GitHub IP fetcher.""" + + @patch("cloudflare_api.ip_groups.fetchers.httpx.Client") + def test_fetch_github_ips(self, mock_client_class): + """Test fetching GitHub IPs.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "hooks": ["192.168.1.1/32", "192.168.1.2/32"], + "actions": ["10.0.0.1/24", "10.0.0.2/24"], + "web": ["172.16.0.1/16"], + } + mock_response.raise_for_status = MagicMock() + + mock_client = MagicMock() + mock_client.get.return_value = mock_response + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=None) + mock_client_class.return_value = mock_client + + config = IPSourceConfig( + type=SourceType.GITHUB, + services=["hooks", "actions"], + ) + fetcher = GitHubIPFetcher() + ips = fetcher.fetch(config) + + assert len(ips) == 4 + assert "192.168.1.1/32" in ips + assert "10.0.0.1/24" in ips + # web should not be included (not in services filter) + assert "172.16.0.1/16" not in ips + + +class TestGoogleCloudIPFetcher: + """Tests for Google Cloud IP fetcher.""" + + @patch("cloudflare_api.ip_groups.fetchers.httpx.Client") + def test_fetch_gcp_ips(self, mock_client_class): + """Test fetching Google Cloud IPs.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "prefixes": [ + {"ipv4Prefix": "34.0.0.0/8", "scope": "us-central1"}, + {"ipv4Prefix": "35.0.0.0/8", "scope": "europe-west1"}, + {"ipv6Prefix": "2600:1900::/28", "scope": "us-central1"}, + ] + } + mock_response.raise_for_status = MagicMock() + + mock_client = MagicMock() + mock_client.get.return_value = mock_response + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=None) + mock_client_class.return_value = mock_client + + config = IPSourceConfig( + type=SourceType.GOOGLE_CLOUD, + ip_version=4, + ) + fetcher = GoogleCloudIPFetcher() + ips = fetcher.fetch(config) + + assert len(ips) == 2 + assert "34.0.0.0/8" in ips + assert "35.0.0.0/8" in ips + # IPv6 should be filtered out + assert "2600:1900::/28" not in ips + + @patch("cloudflare_api.ip_groups.fetchers.httpx.Client") + def test_fetch_gcp_ips_with_region_filter(self, mock_client_class): + """Test filtering Google Cloud IPs by region.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "prefixes": [ + {"ipv4Prefix": "34.0.0.0/8", "scope": "us-central1"}, + {"ipv4Prefix": "35.0.0.0/8", "scope": "europe-west1"}, + ] + } + mock_response.raise_for_status = MagicMock() + + mock_client = MagicMock() + mock_client.get.return_value = mock_response + mock_client.__enter__ = MagicMock(return_value=mock_client) + mock_client.__exit__ = MagicMock(return_value=None) + mock_client_class.return_value = mock_client + + config = IPSourceConfig( + type=SourceType.GOOGLE_CLOUD, + regions=["us-central1"], + ) + fetcher = GoogleCloudIPFetcher() + ips = fetcher.fetch(config) + + assert len(ips) == 1 + assert "34.0.0.0/8" in ips diff --git a/packages/cloudflare-api/tests/test_models.py b/packages/cloudflare-api/tests/test_models.py new file mode 100644 index 0000000..b4db03b --- /dev/null +++ b/packages/cloudflare-api/tests/test_models.py @@ -0,0 +1,149 @@ +"""Tests for cloudflare_api models.""" + + +from cloudflare_api.models import ( + BulkOperation, + BulkOperationStatus, + IPList, + IPListItem, + IPListItemInput, + ListKind, +) + + +class TestListKind: + """Test suite for ListKind enum.""" + + def test_all_kinds_exist(self): + """Test all expected list kinds are defined.""" + assert ListKind.IP.value == "ip" + assert ListKind.REDIRECT.value == "redirect" + assert ListKind.HOSTNAME.value == "hostname" + assert ListKind.ASN.value == "asn" + + def test_kind_from_string(self): + """Test creating ListKind from string.""" + assert ListKind("ip") == ListKind.IP + assert ListKind("redirect") == ListKind.REDIRECT + + +class TestBulkOperationStatus: + """Test suite for BulkOperationStatus enum.""" + + def test_all_statuses_exist(self): + """Test all expected statuses are defined.""" + assert BulkOperationStatus.PENDING.value == "pending" + assert BulkOperationStatus.RUNNING.value == "running" + assert BulkOperationStatus.COMPLETED.value == "completed" + assert BulkOperationStatus.FAILED.value == "failed" + + +class TestIPListItem: + """Test suite for IPListItem model.""" + + def test_create_item_minimal(self): + """Test creating item with minimal fields.""" + item = IPListItem(ip="192.168.1.1") + + assert item.ip == "192.168.1.1" + assert item.id is None + assert item.comment is None + + def test_create_item_full(self): + """Test creating item with all fields.""" + item = IPListItem( + id="item-123", + ip="10.0.0.0/8", + comment="Private network", + ) + + assert item.id == "item-123" + assert item.ip == "10.0.0.0/8" + assert item.comment == "Private network" + + +class TestIPList: + """Test suite for IPList model.""" + + def test_create_list(self): + """Test creating IP list model.""" + ip_list = IPList( + id="list-123", + name="blocked-ips", + description="Blocked IP addresses", + kind=ListKind.IP, + num_items=10, + ) + + assert ip_list.id == "list-123" + assert ip_list.name == "blocked-ips" + assert ip_list.description == "Blocked IP addresses" + assert ip_list.kind == ListKind.IP + assert ip_list.num_items == 10 + + def test_create_list_defaults(self): + """Test IP list default values.""" + ip_list = IPList(id="list-123", name="test") + + assert ip_list.description is None + assert ip_list.kind == ListKind.IP + assert ip_list.num_items == 0 + assert ip_list.num_referencing_filters == 0 + + +class TestBulkOperation: + """Test suite for BulkOperation model.""" + + def test_create_operation_pending(self): + """Test creating pending bulk operation.""" + op = BulkOperation( + id="op-123", + status=BulkOperationStatus.PENDING, + ) + + assert op.id == "op-123" + assert op.status == BulkOperationStatus.PENDING + assert op.error is None + + def test_create_operation_failed(self): + """Test creating failed bulk operation.""" + op = BulkOperation( + id="op-123", + status=BulkOperationStatus.FAILED, + error="Invalid IP format", + ) + + assert op.status == BulkOperationStatus.FAILED + assert op.error == "Invalid IP format" + + +class TestIPListItemInput: + """Test suite for IPListItemInput model.""" + + def test_create_input_minimal(self): + """Test creating input with minimal fields.""" + input_item = IPListItemInput(ip="1.2.3.4") + + assert input_item.ip == "1.2.3.4" + assert input_item.comment is None + + def test_create_input_with_comment(self): + """Test creating input with comment.""" + input_item = IPListItemInput(ip="1.2.3.4", comment="Bad actor") + + assert input_item.ip == "1.2.3.4" + assert input_item.comment == "Bad actor" + + def test_to_api_dict_minimal(self): + """Test to_api_dict with minimal fields.""" + input_item = IPListItemInput(ip="1.2.3.4") + result = input_item.to_api_dict() + + assert result == {"ip": "1.2.3.4"} + + def test_to_api_dict_with_comment(self): + """Test to_api_dict includes comment when present.""" + input_item = IPListItemInput(ip="1.2.3.4", comment="Test") + result = input_item.to_api_dict() + + assert result == {"ip": "1.2.3.4", "comment": "Test"} diff --git a/packages/cloudflare-api/tests/test_settings.py b/packages/cloudflare-api/tests/test_settings.py new file mode 100644 index 0000000..60a1780 --- /dev/null +++ b/packages/cloudflare-api/tests/test_settings.py @@ -0,0 +1,118 @@ +"""Tests for cloudflare_api settings module.""" + +import os +from unittest.mock import patch + +import pytest +from cloudflare_api.settings import ( + CloudflareAPISettings, + get_cloudflare_api_settings, + reset_settings, +) +from pydantic import ValidationError + + +class TestCloudflareAPISettings: + """Test suite for CloudflareAPISettings.""" + + def test_required_fields_from_env(self, mock_env_vars): + """Test that required fields are loaded from environment.""" + settings = CloudflareAPISettings() + + assert settings.cloudflare_account_id == "test-account-id-12345" + assert settings.get_token_value() == "test-api-token-secret" + + def test_missing_required_fields_raises_error(self): + """Test that missing required fields raise ValidationError.""" + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(ValidationError): + CloudflareAPISettings() + + def test_default_values(self, mock_env_vars): + """Test default configuration values.""" + settings = CloudflareAPISettings() + + assert settings.default_list_kind == "ip" + assert settings.request_timeout == 30 + assert settings.max_retries == 3 + assert settings.bulk_operation_poll_interval == 1.0 + assert settings.bulk_operation_timeout == 300 + + def test_optional_fields_default_to_none(self, mock_env_vars): + """Test that optional fields default to None.""" + settings = CloudflareAPISettings() + + assert settings.cloudflare_api_email is None + assert settings.cloudflare_api_key is None + assert settings.cloudflare_zone_id is None + + def test_list_kind_validation_valid(self, mock_env_vars): + """Test valid list kind values.""" + for kind in ["ip", "redirect", "hostname", "asn"]: + with patch.dict(os.environ, {"CF_DEFAULT_LIST_KIND": kind}): + reset_settings() + settings = CloudflareAPISettings() + assert settings.default_list_kind == kind + + def test_list_kind_validation_invalid(self, mock_env_vars): + """Test that invalid list kind raises error.""" + with patch.dict(os.environ, {"CF_DEFAULT_LIST_KIND": "invalid"}): + with pytest.raises(ValidationError): + CloudflareAPISettings() + + def test_list_kind_case_insensitive(self, mock_env_vars): + """Test that list kind validation is case insensitive.""" + with patch.dict(os.environ, {"CF_DEFAULT_LIST_KIND": "IP"}): + reset_settings() + settings = CloudflareAPISettings() + assert settings.default_list_kind == "ip" + + def test_get_token_value(self, mock_env_vars): + """Test get_token_value returns plain string.""" + settings = CloudflareAPISettings() + token = settings.get_token_value() + + assert isinstance(token, str) + assert token == "test-api-token-secret" + + def test_custom_timeout_values(self, mock_env_vars): + """Test custom timeout configuration.""" + with patch.dict( + os.environ, + { + "CF_REQUEST_TIMEOUT": "60", + "CF_BULK_TIMEOUT": "600", + "CF_BULK_POLL_INTERVAL": "2.5", + }, + ): + reset_settings() + settings = CloudflareAPISettings() + + assert settings.request_timeout == 60 + assert settings.bulk_operation_timeout == 600 + assert settings.bulk_operation_poll_interval == 2.5 + + +class TestGetCloudflareAPISettings: + """Test suite for get_cloudflare_api_settings function.""" + + def test_returns_settings_instance(self, mock_env_vars): + """Test that get_cloudflare_api_settings returns settings.""" + settings = get_cloudflare_api_settings() + + assert isinstance(settings, CloudflareAPISettings) + + def test_returns_singleton(self, mock_env_vars): + """Test that get_cloudflare_api_settings returns same instance.""" + settings1 = get_cloudflare_api_settings() + settings2 = get_cloudflare_api_settings() + + assert settings1 is settings2 + + def test_reset_creates_new_instance(self, mock_env_vars): + """Test that reset_settings allows new instance creation.""" + settings1 = get_cloudflare_api_settings() + reset_settings() + settings2 = get_cloudflare_api_settings() + + assert settings1 is not settings2 diff --git a/packages/cloudflare-auth/pyproject.toml b/packages/cloudflare-auth/pyproject.toml index 60f7c51..4ea7fb5 100644 --- a/packages/cloudflare-auth/pyproject.toml +++ b/packages/cloudflare-auth/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ - "pydantic>=2.0.0", + "pydantic[email]>=2.0.0", "pydantic-settings>=2.0.0", "pyjwt>=2.8.0", "cryptography>=41.0.0", diff --git a/packages/cloudflare-auth/src/cloudflare_auth/models.py b/packages/cloudflare-auth/src/cloudflare_auth/models.py index 4cb5426..1c4a9d5 100644 --- a/packages/cloudflare-auth/src/cloudflare_auth/models.py +++ b/packages/cloudflare-auth/src/cloudflare_auth/models.py @@ -17,7 +17,7 @@ - Application endpoints: For accessing user information """ -from datetime import UTC, datetime +from datetime import datetime, timezone from typing import Any from pydantic import BaseModel, EmailStr, Field @@ -75,7 +75,7 @@ def issued_at(self) -> datetime: Returns: Datetime when token was issued """ - return datetime.fromtimestamp(self.iat, tz=UTC) + return datetime.fromtimestamp(self.iat, tz=timezone.utc) @property def expires_at(self) -> datetime: @@ -84,7 +84,7 @@ def expires_at(self) -> datetime: Returns: Datetime when token expires """ - return datetime.fromtimestamp(self.exp, tz=UTC) + return datetime.fromtimestamp(self.exp, tz=timezone.utc) @property def is_expired(self) -> bool: @@ -93,7 +93,7 @@ def is_expired(self) -> bool: Returns: True if token is expired """ - return datetime.now(tz=UTC) >= self.expires_at + return datetime.now(tz=timezone.utc) >= self.expires_at def get_audience_list(self) -> list[str]: """Get audience as a list. diff --git a/packages/cloudflare-auth/src/cloudflare_auth/rate_limiter.py b/packages/cloudflare-auth/src/cloudflare_auth/rate_limiter.py index 3b0c1af..697c058 100644 --- a/packages/cloudflare-auth/src/cloudflare_auth/rate_limiter.py +++ b/packages/cloudflare-auth/src/cloudflare_auth/rate_limiter.py @@ -23,7 +23,7 @@ import logging from collections import defaultdict -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta, timezone from threading import Lock logger = logging.getLogger(__name__) @@ -73,7 +73,7 @@ def __init__( # Store: IP -> list of attempt timestamps self.attempts: dict[str, list[datetime]] = defaultdict(list) self.lock = Lock() - self.last_cleanup = datetime.now(tz=UTC) + self.last_cleanup = datetime.now(tz=timezone.utc) logger.info( "Initialized rate limiter: %d attempts per %d seconds", @@ -93,7 +93,7 @@ def is_allowed(self, identifier: str) -> bool: with self.lock: self._cleanup_if_needed() - current_time = datetime.now(tz=UTC) + current_time = datetime.now(tz=timezone.utc) cutoff_time = current_time - timedelta(seconds=self.window_seconds) # Get attempts within window @@ -124,7 +124,7 @@ def record_attempt(self, identifier: str) -> None: identifier: IP address or other identifier """ with self.lock: - self.attempts[identifier].append(datetime.now(tz=UTC)) + self.attempts[identifier].append(datetime.now(tz=timezone.utc)) def reset(self, identifier: str) -> None: """Reset rate limit for an identifier. @@ -147,7 +147,7 @@ def get_remaining_attempts(self, identifier: str) -> int: Number of remaining attempts """ with self.lock: - current_time = datetime.now(tz=UTC) + current_time = datetime.now(tz=timezone.utc) cutoff_time = current_time - timedelta(seconds=self.window_seconds) if identifier not in self.attempts: @@ -175,7 +175,7 @@ def get_retry_after(self, identifier: str) -> int: if identifier not in self.attempts or not self.attempts[identifier]: return 0 - current_time = datetime.now(tz=UTC) + current_time = datetime.now(tz=timezone.utc) cutoff_time = current_time - timedelta(seconds=self.window_seconds) # Find oldest attempt in window @@ -200,7 +200,7 @@ def _cleanup_if_needed(self) -> None: Note: Must be called while holding self.lock """ - current_time = datetime.now(tz=UTC) + current_time = datetime.now(tz=timezone.utc) if (current_time - self.last_cleanup).total_seconds() < self.cleanup_interval: return diff --git a/packages/cloudflare-auth/src/cloudflare_auth/redis_sessions.py b/packages/cloudflare-auth/src/cloudflare_auth/redis_sessions.py index 1376639..8fde66a 100644 --- a/packages/cloudflare-auth/src/cloudflare_auth/redis_sessions.py +++ b/packages/cloudflare-auth/src/cloudflare_auth/redis_sessions.py @@ -37,7 +37,7 @@ import json import logging import secrets -from datetime import UTC, datetime +from datetime import datetime, timezone from typing import Any _redis_available: bool @@ -174,8 +174,8 @@ def create_session( "email": email, "is_admin": is_admin, "user_tier": user_tier, - "created_at": datetime.now(tz=UTC).isoformat(), - "last_accessed": datetime.now(tz=UTC).isoformat(), + "created_at": datetime.now(tz=timezone.utc).isoformat(), + "last_accessed": datetime.now(tz=timezone.utc).isoformat(), "cf_context": cf_context or {}, } @@ -221,7 +221,7 @@ def get_session(self, session_id: str) -> dict[str, Any] | None: session_data = json.loads(str(session_data_json)) # Update last accessed timestamp - session_data["last_accessed"] = datetime.now(tz=UTC).isoformat() + session_data["last_accessed"] = datetime.now(tz=timezone.utc).isoformat() # Update in Redis and refresh TTL self.redis_client.setex(key, self.session_timeout, json.dumps(session_data)) diff --git a/packages/cloudflare-auth/src/cloudflare_auth/sessions.py b/packages/cloudflare-auth/src/cloudflare_auth/sessions.py index a473ddb..786303f 100644 --- a/packages/cloudflare-auth/src/cloudflare_auth/sessions.py +++ b/packages/cloudflare-auth/src/cloudflare_auth/sessions.py @@ -19,7 +19,7 @@ import logging import secrets -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Any logger = logging.getLogger(__name__) @@ -101,8 +101,8 @@ def create_session( "email": email, "is_admin": is_admin, "user_tier": user_tier, - "created_at": datetime.now(tz=UTC), - "last_accessed": datetime.now(tz=UTC), + "created_at": datetime.now(tz=timezone.utc), + "last_accessed": datetime.now(tz=timezone.utc), "cf_context": cf_context or {}, } @@ -148,7 +148,7 @@ def get_session(self, session_id: str) -> dict[str, Any] | None: return None # Update last accessed time - session["last_accessed"] = datetime.now(tz=UTC) + session["last_accessed"] = datetime.now(tz=timezone.utc) return session def invalidate_session(self, session_id: str) -> bool: @@ -183,7 +183,7 @@ def refresh_session(self, session_id: str) -> bool: """ session = self.sessions.get(session_id) if session: - session["last_accessed"] = datetime.now(tz=UTC) + session["last_accessed"] = datetime.now(tz=timezone.utc) return True return False @@ -197,7 +197,7 @@ def _is_session_expired(self, session: dict[str, Any]) -> bool: True if session has exceeded timeout """ expiry = session["last_accessed"] + timedelta(seconds=self.session_timeout) - return datetime.now(tz=UTC) >= expiry + return datetime.now(tz=timezone.utc) >= expiry def cleanup_expired_sessions(self) -> int: """Remove expired sessions from memory. @@ -276,7 +276,7 @@ def get_session_info(self, session_id: str) -> dict[str, Any] | None: "created_at": session["created_at"].isoformat(), "last_accessed": session["last_accessed"].isoformat(), "age_seconds": ( - datetime.now(tz=UTC) - session["created_at"] + datetime.now(tz=timezone.utc) - session["created_at"] ).total_seconds(), } @@ -286,7 +286,7 @@ def get_stats(self) -> dict[str, Any]: Returns: Dictionary with session statistics """ - datetime.now(tz=UTC) + datetime.now(tz=timezone.utc) active_sessions = [] expired_sessions = [] diff --git a/packages/cloudflare-auth/src/cloudflare_auth/settings.py b/packages/cloudflare-auth/src/cloudflare_auth/settings.py new file mode 100644 index 0000000..7a66bf6 --- /dev/null +++ b/packages/cloudflare-auth/src/cloudflare_auth/settings.py @@ -0,0 +1,111 @@ +"""Cloudflare Access configuration settings. + +Hybrid approach: reads from environment by default, but accepts injected settings. +""" + + +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class CloudflareSettings(BaseSettings): + """Configuration for Cloudflare Access authentication.""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + case_sensitive=False, + populate_by_name=True, + ) + + # Required + cloudflare_team_domain: str = Field(default="", alias="CLOUDFLARE_TEAM_DOMAIN") + cloudflare_audience_tag: str = Field(default="", alias="CLOUDFLARE_AUDIENCE_TAG") + cloudflare_enabled: bool = Field(default=True, alias="CLOUDFLARE_ENABLED") + + # Headers + jwt_header_name: str = Field( + default="Cf-Access-Jwt-Assertion", alias="CF_JWT_HEADER" + ) + email_header_name: str = Field( + default="Cf-Access-Authenticated-User-Email", alias="CF_EMAIL_HEADER" + ) + + # Security + require_email_verification: bool = Field( + default=True, alias="CF_REQUIRE_EMAIL_VERIFICATION" + ) + log_auth_failures: bool = Field(default=True, alias="CF_LOG_AUTH_FAILURES") + require_cloudflare_headers: bool = Field( + default=True, alias="CF_REQUIRE_CLOUDFLARE_HEADERS" + ) + + # Access control + allowed_email_domains: list[str] = Field( + default_factory=list, alias="CF_ALLOWED_EMAIL_DOMAINS" + ) + allowed_tunnel_ips: list[str] = Field( + default_factory=list, alias="CF_ALLOWED_TUNNEL_IPS" + ) + + # Cookies + cookie_domain: str | None = Field(default=None, alias="CF_COOKIE_DOMAIN") + cookie_path: str = Field(default="/", alias="CF_COOKIE_PATH") + cookie_secure: bool = Field(default=True, alias="CF_COOKIE_SECURE") + cookie_samesite: str = Field(default="lax", alias="CF_COOKIE_SAMESITE") + + # JWT + jwt_algorithm: str = Field(default="RS256", alias="CF_JWT_ALGORITHM") + jwt_cache_max_keys: int = Field(default=16, alias="CF_JWT_CACHE_MAX_KEYS") + + @field_validator("allowed_email_domains", "allowed_tunnel_ips", mode="before") + @classmethod + def parse_comma_separated(cls, v: str | list[str] | None) -> list[str]: + """Parse comma-separated string into list.""" + if isinstance(v, str): + return ( + [item.strip() for item in v.split(",") if item.strip()] + if v.strip() + else [] + ) + return v or [] + + @property + def issuer(self) -> str: + """Get the Cloudflare issuer URL.""" + if not self.cloudflare_team_domain: + return "" + domain = self.cloudflare_team_domain.rstrip("/") + return f"https://{domain}" if not domain.startswith("https://") else domain + + @property + def certs_url(self) -> str: + """Get the Cloudflare certs URL.""" + return f"{self.issuer}/cdn-cgi/access/certs" if self.issuer else "" + + def is_email_allowed(self, email: str) -> bool: + """Check if an email is allowed based on domain restrictions.""" + if not self.allowed_email_domains: + return True + if "@" not in email: + return False + domain = email.split("@")[-1].lower() + return domain in [d.lower() for d in self.allowed_email_domains] + + +_settings_instance: CloudflareSettings | None = None + + +def get_cloudflare_settings() -> CloudflareSettings: + """Get default settings (singleton, reads from environment).""" + global _settings_instance # noqa: PLW0603 + if _settings_instance is None: + _settings_instance = CloudflareSettings() + return _settings_instance + + +def reset_settings() -> None: + """Reset singleton (for testing).""" + global _settings_instance # noqa: PLW0603 + _settings_instance = None diff --git a/packages/cloudflare-auth/src/cloudflare_auth/validators.py b/packages/cloudflare-auth/src/cloudflare_auth/validators.py index 1eeffc5..4cb1cd4 100644 --- a/packages/cloudflare-auth/src/cloudflare_auth/validators.py +++ b/packages/cloudflare-auth/src/cloudflare_auth/validators.py @@ -29,7 +29,7 @@ """ import logging -from datetime import UTC, datetime +from datetime import datetime, timezone from typing import Any import jwt @@ -267,7 +267,7 @@ def refresh_keys(self) -> None: cache_keys=True, max_cached_keys=self.settings.jwt_cache_max_keys, ) - self._last_key_refresh = datetime.now(tz=UTC) + self._last_key_refresh = datetime.now(tz=timezone.utc) logger.info("Cloudflare public keys client refreshed") @property diff --git a/packages/cloudflare-auth/tests/conftest.py b/packages/cloudflare-auth/tests/conftest.py index 437c3ad..c19fe67 100644 --- a/packages/cloudflare-auth/tests/conftest.py +++ b/packages/cloudflare-auth/tests/conftest.py @@ -1,16 +1,61 @@ """Pytest configuration for cloudflare-auth tests.""" +from datetime import datetime, timezone + import pytest +from cloudflare_auth.settings import reset_settings + +# Test constants +TEST_ISSUER = "https://test.cloudflareaccess.com" +TEST_EMAIL = "test@example.com" +TEST_USER_ID = "test-user-id" +TEST_AUDIENCE = "test-audience" + @pytest.fixture def sample_jwt_payload(): """Sample JWT payload for testing.""" return { - "iss": "https://test.cloudflareaccess.com", - "sub": "test-user-id", - "aud": ["test-audience"], - "email": "test@example.com", + "iss": TEST_ISSUER, + "sub": TEST_USER_ID, + "aud": [TEST_AUDIENCE], + "email": TEST_EMAIL, "iat": 1700000000, "exp": 1700003600, } + + +@pytest.fixture +def valid_jwt_payload(): + """JWT payload with future expiration.""" + now = int(datetime.now(tz=timezone.utc).timestamp()) + return { + "iss": TEST_ISSUER, + "sub": TEST_USER_ID, + "aud": [TEST_AUDIENCE], + "email": TEST_EMAIL, + "iat": now, + "exp": now + 3600, + } + + +@pytest.fixture +def expired_jwt_payload(): + """JWT payload with past expiration.""" + now = int(datetime.now(tz=timezone.utc).timestamp()) + return { + "iss": TEST_ISSUER, + "sub": TEST_USER_ID, + "aud": [TEST_AUDIENCE], + "email": TEST_EMAIL, + "iat": now - 7200, + "exp": now - 3600, + } + + +@pytest.fixture(autouse=True) +def reset_settings_after_test(): + """Reset settings singleton after each test.""" + yield + reset_settings() diff --git a/packages/cloudflare-auth/tests/test_integration.py b/packages/cloudflare-auth/tests/test_integration.py new file mode 100644 index 0000000..79455f3 --- /dev/null +++ b/packages/cloudflare-auth/tests/test_integration.py @@ -0,0 +1,359 @@ +"""Integration tests for cloudflare_auth module. + +These tests verify that components work together correctly. +""" + +from datetime import datetime, timezone + +import pytest + +from cloudflare_auth.models import CloudflareJWTClaims, CloudflareUser +from cloudflare_auth.sessions import SimpleSessionManager +from cloudflare_auth.settings import CloudflareSettings, reset_settings +from cloudflare_auth.whitelist import EmailWhitelistValidator, UserTier + + +class TestAuthenticationFlow: + """Test complete authentication flow integration.""" + + @pytest.fixture + def settings(self): + """Create test settings.""" + return CloudflareSettings( + cloudflare_team_domain="test.cloudflareaccess.com", + cloudflare_audience_tag="test-audience", + cloudflare_enabled=True, + ) + + @pytest.fixture + def whitelist_validator(self): + """Create whitelist validator.""" + return EmailWhitelistValidator( + whitelist=["@company.com", "guest@external.com"], + admin_emails=["admin@company.com"], + full_users=["@company.com"], + limited_users=["guest@external.com"], + ) + + @pytest.fixture + def session_manager(self): + """Create session manager.""" + return SimpleSessionManager(session_timeout=3600) + + @pytest.fixture + def sample_claims(self): + """Create sample JWT claims.""" + now = int(datetime.now(tz=timezone.utc).timestamp()) + return CloudflareJWTClaims( + email="user@company.com", + iss="https://test.cloudflareaccess.com", + aud=["test-audience"], + sub="user-id-123", + iat=now, + exp=now + 3600, + ) + + def test_full_auth_flow_admin(self, whitelist_validator, session_manager): + """Test complete authentication flow for admin user.""" + email = "admin@company.com" + now = int(datetime.now(tz=timezone.utc).timestamp()) + + # 1. Create claims (simulating JWT validation) + claims = CloudflareJWTClaims( + email=email, + iss="https://test.cloudflareaccess.com", + aud=["test-audience"], + sub="admin-user-id", + iat=now, + exp=now + 3600, + ) + + # 2. Check whitelist authorization + assert whitelist_validator.is_authorized(email) is True + + # 3. Get user tier + tier = whitelist_validator.get_user_tier(email) + assert tier == UserTier.ADMIN + + # 4. Create session + session_id = session_manager.create_session( + email=email, + is_admin=tier.has_admin_privileges, + user_tier=tier.value, + ) + + # 5. Create user object + user = CloudflareUser.from_jwt_claims( + claims=claims, + user_tier=tier, + is_admin=tier.has_admin_privileges, + session_id=session_id, + ) + + # Verify user has expected properties + assert user.email == email + assert user.user_tier == UserTier.ADMIN + assert user.is_admin is True + assert user.can_access_premium_models is True + assert user.session_id == session_id + + # 6. Verify session can be retrieved + session = session_manager.get_session(session_id) + assert session is not None + assert session["email"] == email + assert session["is_admin"] is True + + def test_full_auth_flow_regular_user(self, whitelist_validator, session_manager): + """Test complete authentication flow for regular user.""" + email = "developer@company.com" + now = int(datetime.now(tz=timezone.utc).timestamp()) + + claims = CloudflareJWTClaims( + email=email, + iss="https://test.cloudflareaccess.com", + aud=["test-audience"], + sub="dev-user-id", + iat=now, + exp=now + 3600, + ) + + assert whitelist_validator.is_authorized(email) is True + tier = whitelist_validator.get_user_tier(email) + assert tier == UserTier.FULL + + session_id = session_manager.create_session( + email=email, + is_admin=tier.has_admin_privileges, + user_tier=tier.value, + ) + + user = CloudflareUser.from_jwt_claims( + claims=claims, + user_tier=tier, + is_admin=tier.has_admin_privileges, + session_id=session_id, + ) + + assert user.is_admin is False + assert user.can_access_premium_models is True + + def test_full_auth_flow_limited_user(self, whitelist_validator, session_manager): + """Test complete authentication flow for limited user.""" + email = "guest@external.com" + now = int(datetime.now(tz=timezone.utc).timestamp()) + + claims = CloudflareJWTClaims( + email=email, + iss="https://test.cloudflareaccess.com", + aud=["test-audience"], + sub="guest-user-id", + iat=now, + exp=now + 3600, + ) + + assert whitelist_validator.is_authorized(email) is True + tier = whitelist_validator.get_user_tier(email) + assert tier == UserTier.LIMITED + + user = CloudflareUser.from_jwt_claims( + claims=claims, + user_tier=tier, + is_admin=tier.has_admin_privileges, + ) + + assert user.is_admin is False + assert user.can_access_premium_models is False + + def test_unauthorized_user_flow(self, whitelist_validator): + """Test authentication flow for unauthorized user.""" + email = "hacker@malicious.com" + + assert whitelist_validator.is_authorized(email) is False + + with pytest.raises(ValueError, match="not authorized"): + whitelist_validator.get_user_tier(email) + + +class TestSessionIntegration: + """Test session management integration.""" + + def test_session_lifecycle(self): + """Test complete session lifecycle.""" + manager = SimpleSessionManager(session_timeout=3600) + + # Create session + session_id = manager.create_session( + email="test@example.com", + is_admin=False, + user_tier="full", + cf_context={"cf_ray": "abc123"}, + ) + + # Verify session exists + assert manager.get_session(session_id) is not None + assert manager.get_session_count() == 1 + + # Refresh session + assert manager.refresh_session(session_id) is True + + # Get session info + info = manager.get_session_info(session_id) + assert info["email"] == "test@example.com" + + # Invalidate session + assert manager.invalidate_session(session_id) is True + assert manager.get_session(session_id) is None + assert manager.get_session_count() == 0 + + def test_multiple_user_sessions(self): + """Test handling multiple sessions for same and different users.""" + manager = SimpleSessionManager(session_timeout=3600) + + # Create sessions for user1 + user1_session1 = manager.create_session( + email="user1@example.com", + is_admin=False, + user_tier="full", + ) + user1_session2 = manager.create_session( + email="user1@example.com", + is_admin=False, + user_tier="full", + ) + + # Create session for user2 + user2_session = manager.create_session( + email="user2@example.com", + is_admin=True, + user_tier="admin", + ) + + # Check session counts + assert manager.get_session_count() == 3 + + # Check user-specific sessions + user1_sessions = manager.get_user_sessions("user1@example.com") + assert len(user1_sessions) == 2 + assert user1_session1 in user1_sessions + assert user1_session2 in user1_sessions + + user2_sessions = manager.get_user_sessions("user2@example.com") + assert len(user2_sessions) == 1 + assert user2_session in user2_sessions + + +class TestSettingsIntegration: + """Test settings integration with other components.""" + + def teardown_method(self): + """Reset settings after each test.""" + reset_settings() + + def test_settings_email_domain_restriction(self): + """Test email domain restriction in settings.""" + settings = CloudflareSettings( + cloudflare_team_domain="test.cloudflareaccess.com", + allowed_email_domains=["company.com", "partner.com"], + ) + + # Create whitelist that uses same domain pattern + validator = EmailWhitelistValidator( + whitelist=["@company.com", "@partner.com", "@external.com"], + ) + + # Both settings and whitelist should agree on company.com + email = "user@company.com" + assert settings.is_email_allowed(email) is True + assert validator.is_authorized(email) is True + + # external.com is in whitelist but not allowed by settings + email = "user@external.com" + assert settings.is_email_allowed(email) is False + assert validator.is_authorized(email) is True # Whitelist allows it + + +class TestUserTierIntegration: + """Test user tier integration across components.""" + + def test_tier_propagation(self): + """Test that tier information propagates correctly.""" + whitelist = EmailWhitelistValidator( + whitelist=["@company.com"], + admin_emails=["ceo@company.com"], + full_users=["@company.com"], + ) + + session_manager = SimpleSessionManager() + + for email, expected_tier in [ + ("ceo@company.com", UserTier.ADMIN), + ("developer@company.com", UserTier.FULL), + ]: + tier = whitelist.get_user_tier(email) + assert tier == expected_tier + + session_id = session_manager.create_session( + email=email, + is_admin=tier.has_admin_privileges, + user_tier=tier.value, + ) + + session = session_manager.get_session(session_id) + assert session["user_tier"] == tier.value + assert session["is_admin"] == tier.has_admin_privileges + + +class TestSecurityIntegration: + """Test security-related integration scenarios.""" + + def test_constant_time_comparison_in_whitelist(self): + """Test that whitelist uses constant-time comparison.""" + validator = EmailWhitelistValidator( + whitelist=["secret-admin@company.com"], + admin_emails=["secret-admin@company.com"], + ) + + # These should all take similar time regardless of match + # (testing that secrets.compare_digest is used) + validator.is_authorized("secret-admin@company.com") # Match + validator.is_authorized("xxxxxx-xxxxx@company.com") # Similar length, no match + validator.is_authorized("a@b.com") # Short, no match + + def test_session_id_uniqueness(self): + """Test that session IDs are unique and unpredictable.""" + manager = SimpleSessionManager() + session_ids = set() + + for _ in range(1000): + session_id = manager.create_session( + email="test@example.com", + is_admin=False, + user_tier="full", + ) + assert session_id not in session_ids, "Duplicate session ID generated" + session_ids.add(session_id) + + def test_model_dump_safe_excludes_sensitive_data(self): + """Test that safe dump excludes sensitive claims.""" + now = int(datetime.now(tz=timezone.utc).timestamp()) + claims = CloudflareJWTClaims( + email="test@example.com", + iss="https://private-issuer.com", + aud=["private-audience-tag"], + sub="user-id-12345", + iat=now, + exp=now + 3600, + nonce="private-nonce-value", + custom={"private_key": "private_value"}, + ) + + user = CloudflareUser.from_jwt_claims(claims) + safe_data = user.model_dump_safe() + + # Safe data should not contain sensitive claim details + assert "claims" not in safe_data + assert "nonce" not in safe_data + assert "iss" not in safe_data + assert "aud" not in safe_data + # The sub becomes user_id which is expected + assert "private" not in str(safe_data).lower() diff --git a/packages/cloudflare-auth/tests/test_models.py b/packages/cloudflare-auth/tests/test_models.py index 4a24cc5..34ff59f 100644 --- a/packages/cloudflare-auth/tests/test_models.py +++ b/packages/cloudflare-auth/tests/test_models.py @@ -1,10 +1,208 @@ """Tests for cloudflare_auth models.""" +from datetime import datetime, timezone -class TestModels: - """Test suite for cloudflare_auth models.""" +import pytest - def test_placeholder(self) -> None: - """Placeholder test - replace with actual tests.""" - # TODO: Add actual model tests once dependencies are resolved - assert True +from cloudflare_auth.models import CloudflareJWTClaims, CloudflareUser +from cloudflare_auth.whitelist import UserTier + + +class TestCloudflareJWTClaims: + """Test suite for CloudflareJWTClaims model.""" + + def test_create_claims_with_required_fields(self, sample_jwt_payload): + """Test creating claims with required fields.""" + claims = CloudflareJWTClaims(**sample_jwt_payload) + + assert claims.email == "test@example.com" + assert claims.iss == "https://test.cloudflareaccess.com" + assert claims.aud == ["test-audience"] + assert claims.sub == "test-user-id" + assert claims.iat == 1700000000 + assert claims.exp == 1700003600 + + def test_claims_with_optional_fields(self, sample_jwt_payload): + """Test claims with optional fields.""" + sample_jwt_payload["nonce"] = "test-nonce" + sample_jwt_payload["identity_nonce"] = "test-identity-nonce" + sample_jwt_payload["custom"] = {"role": "admin"} + + claims = CloudflareJWTClaims(**sample_jwt_payload) + + assert claims.nonce == "test-nonce" + assert claims.identity_nonce == "test-identity-nonce" + assert claims.custom == {"role": "admin"} + + def test_issued_at_property(self, sample_jwt_payload): + """Test issued_at datetime property.""" + claims = CloudflareJWTClaims(**sample_jwt_payload) + issued_at = claims.issued_at + + assert isinstance(issued_at, datetime) + assert issued_at.timestamp() == sample_jwt_payload["iat"] + + def test_expires_at_property(self, sample_jwt_payload): + """Test expires_at datetime property.""" + claims = CloudflareJWTClaims(**sample_jwt_payload) + expires_at = claims.expires_at + + assert isinstance(expires_at, datetime) + assert expires_at.timestamp() == sample_jwt_payload["exp"] + + def test_is_expired_false_for_future_expiration(self, sample_jwt_payload): + """Test is_expired returns False for future expiration.""" + # Set expiration to far in the future + sample_jwt_payload["exp"] = int(datetime.now(tz=timezone.utc).timestamp()) + 3600 + claims = CloudflareJWTClaims(**sample_jwt_payload) + + assert claims.is_expired is False + + def test_is_expired_true_for_past_expiration(self, sample_jwt_payload): + """Test is_expired returns True for past expiration.""" + # Set expiration to the past + sample_jwt_payload["exp"] = int(datetime.now(tz=timezone.utc).timestamp()) - 3600 + claims = CloudflareJWTClaims(**sample_jwt_payload) + + assert claims.is_expired is True + + def test_get_audience_list_from_string(self, sample_jwt_payload): + """Test get_audience_list when aud is a string.""" + sample_jwt_payload["aud"] = "single-audience" + claims = CloudflareJWTClaims(**sample_jwt_payload) + + assert claims.get_audience_list() == ["single-audience"] + + def test_get_audience_list_from_list(self, sample_jwt_payload): + """Test get_audience_list when aud is already a list.""" + claims = CloudflareJWTClaims(**sample_jwt_payload) + + assert claims.get_audience_list() == ["test-audience"] + + +class TestCloudflareUser: + """Test suite for CloudflareUser model.""" + + @pytest.fixture + def sample_claims(self, sample_jwt_payload): + """Create sample claims for user tests.""" + return CloudflareJWTClaims(**sample_jwt_payload) + + def test_create_user_from_claims(self, sample_claims): + """Test creating user from JWT claims.""" + user = CloudflareUser.from_jwt_claims(sample_claims) + + assert user.email == "test@example.com" + assert user.user_id == "test-user-id" + assert user.claims == sample_claims + assert user.user_tier == UserTier.LIMITED + assert user.is_admin is False + assert user.session_id is None + + def test_create_user_with_admin_tier(self, sample_claims): + """Test creating user with admin tier.""" + user = CloudflareUser.from_jwt_claims( + sample_claims, + user_tier=UserTier.ADMIN, + is_admin=True, + ) + + assert user.user_tier == UserTier.ADMIN + assert user.is_admin is True + + def test_create_user_with_session_id(self, sample_claims): + """Test creating user with session ID.""" + user = CloudflareUser.from_jwt_claims( + sample_claims, + session_id="test-session-123", + ) + + assert user.session_id == "test-session-123" + + def test_email_domain_property(self, sample_claims): + """Test email_domain property.""" + user = CloudflareUser.from_jwt_claims(sample_claims) + + assert user.email_domain == "example.com" + + def test_email_username_property(self, sample_claims): + """Test email_username property.""" + user = CloudflareUser.from_jwt_claims(sample_claims) + + assert user.email_username == "test" + + def test_has_email_domain_true(self, sample_claims): + """Test has_email_domain returns True for matching domain.""" + user = CloudflareUser.from_jwt_claims(sample_claims) + + assert user.has_email_domain("example.com") is True + assert user.has_email_domain("EXAMPLE.COM") is True # Case insensitive + + def test_has_email_domain_false(self, sample_claims): + """Test has_email_domain returns False for non-matching domain.""" + user = CloudflareUser.from_jwt_claims(sample_claims) + + assert user.has_email_domain("other.com") is False + + def test_can_access_premium_models_admin(self, sample_claims): + """Test premium access for admin tier.""" + user = CloudflareUser.from_jwt_claims( + sample_claims, + user_tier=UserTier.ADMIN, + ) + + assert user.can_access_premium_models is True + + def test_can_access_premium_models_full(self, sample_claims): + """Test premium access for full tier.""" + user = CloudflareUser.from_jwt_claims( + sample_claims, + user_tier=UserTier.FULL, + ) + + assert user.can_access_premium_models is True + + def test_can_access_premium_models_limited(self, sample_claims): + """Test premium access for limited tier.""" + user = CloudflareUser.from_jwt_claims( + sample_claims, + user_tier=UserTier.LIMITED, + ) + + assert user.can_access_premium_models is False + + def test_role_property_admin(self, sample_claims): + """Test role property for admin user.""" + user = CloudflareUser.from_jwt_claims( + sample_claims, + is_admin=True, + ) + + assert user.role == "admin" + + def test_role_property_user(self, sample_claims): + """Test role property for regular user.""" + user = CloudflareUser.from_jwt_claims(sample_claims) + + assert user.role == "user" + + def test_model_dump_safe(self, sample_claims): + """Test model_dump_safe returns expected fields.""" + user = CloudflareUser.from_jwt_claims( + sample_claims, + user_tier=UserTier.FULL, + is_admin=False, + ) + + safe_dict = user.model_dump_safe() + + assert "email" in safe_dict + assert "user_id" in safe_dict + assert "email_domain" in safe_dict + assert "authenticated_at" in safe_dict + assert "user_tier" in safe_dict + assert "is_admin" in safe_dict + assert "can_access_premium" in safe_dict + assert "role" in safe_dict + # Ensure claims are not included (security) + assert "claims" not in safe_dict diff --git a/packages/cloudflare-auth/tests/test_sessions.py b/packages/cloudflare-auth/tests/test_sessions.py new file mode 100644 index 0000000..2572194 --- /dev/null +++ b/packages/cloudflare-auth/tests/test_sessions.py @@ -0,0 +1,260 @@ +"""Tests for cloudflare_auth sessions module.""" + +import time + +import pytest + +from cloudflare_auth.sessions import SimpleSessionManager + + +class TestSimpleSessionManager: + """Test suite for SimpleSessionManager.""" + + @pytest.fixture + def session_manager(self): + """Create a session manager for testing.""" + return SimpleSessionManager(session_timeout=3600) + + @pytest.fixture + def short_timeout_manager(self): + """Create a session manager with short timeout for expiration tests.""" + return SimpleSessionManager(session_timeout=1) + + def test_create_session(self, session_manager): + """Test creating a new session.""" + session_id = session_manager.create_session( + email="test@example.com", + is_admin=False, + user_tier="full", + ) + + assert session_id is not None + assert len(session_id) > 0 + + def test_create_session_with_context(self, session_manager): + """Test creating session with CF context.""" + cf_context = {"cf_ray": "abc123", "cf_country": "US"} + session_id = session_manager.create_session( + email="test@example.com", + is_admin=True, + user_tier="admin", + cf_context=cf_context, + ) + + session = session_manager.get_session(session_id) + assert session is not None + assert session["cf_context"] == cf_context + + def test_get_session_valid(self, session_manager): + """Test retrieving a valid session.""" + session_id = session_manager.create_session( + email="test@example.com", + is_admin=False, + user_tier="full", + ) + + session = session_manager.get_session(session_id) + + assert session is not None + assert session["email"] == "test@example.com" + assert session["is_admin"] is False + assert session["user_tier"] == "full" + + def test_get_session_not_found(self, session_manager): + """Test retrieving non-existent session.""" + session = session_manager.get_session("nonexistent-session-id") + + assert session is None + + def test_get_session_empty_id(self, session_manager): + """Test retrieving with empty session ID.""" + assert session_manager.get_session("") is None + assert session_manager.get_session(None) is None + + def test_get_session_expired(self, short_timeout_manager): + """Test that expired sessions return None.""" + session_id = short_timeout_manager.create_session( + email="test@example.com", + is_admin=False, + user_tier="full", + ) + + # Wait for session to expire + time.sleep(1.5) + + session = short_timeout_manager.get_session(session_id) + assert session is None + + def test_invalidate_session(self, session_manager): + """Test invalidating a session.""" + session_id = session_manager.create_session( + email="test@example.com", + is_admin=False, + user_tier="full", + ) + + result = session_manager.invalidate_session(session_id) + + assert result is True + assert session_manager.get_session(session_id) is None + + def test_invalidate_session_not_found(self, session_manager): + """Test invalidating non-existent session.""" + result = session_manager.invalidate_session("nonexistent") + + assert result is False + + def test_refresh_session(self, session_manager): + """Test refreshing a session.""" + session_id = session_manager.create_session( + email="test@example.com", + is_admin=False, + user_tier="full", + ) + + initial_session = session_manager.get_session(session_id) + initial_accessed = initial_session["last_accessed"] + + # Small delay to ensure timestamp changes + time.sleep(0.01) + + result = session_manager.refresh_session(session_id) + + assert result is True + + def test_refresh_session_not_found(self, session_manager): + """Test refreshing non-existent session.""" + result = session_manager.refresh_session("nonexistent") + + assert result is False + + def test_cleanup_expired_sessions(self, short_timeout_manager): + """Test cleaning up expired sessions.""" + # Create multiple sessions + short_timeout_manager.create_session( + email="user1@example.com", + is_admin=False, + user_tier="full", + ) + short_timeout_manager.create_session( + email="user2@example.com", + is_admin=False, + user_tier="full", + ) + + # Wait for sessions to expire + time.sleep(1.5) + + count = short_timeout_manager.cleanup_expired_sessions() + + assert count == 2 + assert short_timeout_manager.get_session_count() == 0 + + def test_get_session_count(self, session_manager): + """Test getting session count.""" + assert session_manager.get_session_count() == 0 + + session_manager.create_session( + email="user1@example.com", + is_admin=False, + user_tier="full", + ) + assert session_manager.get_session_count() == 1 + + session_manager.create_session( + email="user2@example.com", + is_admin=False, + user_tier="full", + ) + assert session_manager.get_session_count() == 2 + + def test_get_user_sessions(self, session_manager): + """Test getting all sessions for a user.""" + email = "test@example.com" + + # Create multiple sessions for same user + session1 = session_manager.create_session( + email=email, + is_admin=False, + user_tier="full", + ) + session2 = session_manager.create_session( + email=email, + is_admin=False, + user_tier="full", + ) + + # Create session for different user + session_manager.create_session( + email="other@example.com", + is_admin=False, + user_tier="full", + ) + + user_sessions = session_manager.get_user_sessions(email) + + assert len(user_sessions) == 2 + assert session1 in user_sessions + assert session2 in user_sessions + + def test_get_session_info(self, session_manager): + """Test getting safe session info.""" + session_id = session_manager.create_session( + email="test@example.com", + is_admin=True, + user_tier="admin", + ) + + info = session_manager.get_session_info(session_id) + + assert info is not None + assert info["email"] == "test@example.com" + assert info["is_admin"] is True + assert info["user_tier"] == "admin" + assert "created_at" in info + assert "last_accessed" in info + assert "age_seconds" in info + + def test_get_session_info_not_found(self, session_manager): + """Test getting info for non-existent session.""" + info = session_manager.get_session_info("nonexistent") + + assert info is None + + def test_get_stats(self, session_manager): + """Test getting session statistics.""" + # Create some sessions + session_manager.create_session( + email="admin@example.com", + is_admin=True, + user_tier="admin", + ) + session_manager.create_session( + email="user@example.com", + is_admin=False, + user_tier="full", + ) + + stats = session_manager.get_stats() + + assert stats["total_sessions"] == 2 + assert stats["active_sessions"] == 2 + assert stats["session_timeout"] == 3600 + assert "sessions_by_tier" in stats + + def test_session_id_is_secure(self, session_manager): + """Test that session IDs are cryptographically secure.""" + session_ids = set() + + for _ in range(100): + session_id = session_manager.create_session( + email="test@example.com", + is_admin=False, + user_tier="full", + ) + # Ensure no duplicate session IDs + assert session_id not in session_ids + session_ids.add(session_id) + + # Session ID should be reasonably long + assert len(session_id) >= 32 diff --git a/packages/cloudflare-auth/tests/test_settings.py b/packages/cloudflare-auth/tests/test_settings.py new file mode 100644 index 0000000..ac0df4c --- /dev/null +++ b/packages/cloudflare-auth/tests/test_settings.py @@ -0,0 +1,186 @@ +"""Tests for cloudflare_auth settings module.""" + +import os +from unittest.mock import patch + +from cloudflare_auth.settings import ( + CloudflareSettings, + get_cloudflare_settings, + reset_settings, +) + + +class TestCloudflareSettings: + """Test suite for CloudflareSettings.""" + + def test_default_values(self): + """Test default configuration values.""" + settings = CloudflareSettings() + + assert settings.cloudflare_team_domain == "" + assert settings.cloudflare_audience_tag == "" + assert settings.cloudflare_enabled is True + assert settings.jwt_header_name == "Cf-Access-Jwt-Assertion" + assert settings.email_header_name == "Cf-Access-Authenticated-User-Email" + + def test_security_defaults(self): + """Test security-related defaults.""" + settings = CloudflareSettings() + + assert settings.require_email_verification is True + assert settings.log_auth_failures is True + assert settings.require_cloudflare_headers is True + assert settings.cookie_secure is True + + def test_jwt_defaults(self): + """Test JWT-related defaults.""" + settings = CloudflareSettings() + + assert settings.jwt_algorithm == "RS256" + assert settings.jwt_cache_max_keys == 16 + + def test_cookie_defaults(self): + """Test cookie-related defaults.""" + settings = CloudflareSettings() + + assert settings.cookie_domain is None + assert settings.cookie_path == "/" + assert settings.cookie_samesite == "lax" + + def test_issuer_property_empty(self): + """Test issuer property with empty domain.""" + settings = CloudflareSettings() + + assert settings.issuer == "" + + def test_issuer_property_with_domain(self): + """Test issuer property with domain.""" + settings = CloudflareSettings( + cloudflare_team_domain="myteam.cloudflareaccess.com" + ) + + assert settings.issuer == "https://myteam.cloudflareaccess.com" + + def test_issuer_property_with_https_prefix(self): + """Test issuer property when domain already has https.""" + settings = CloudflareSettings( + cloudflare_team_domain="https://myteam.cloudflareaccess.com" + ) + + assert settings.issuer == "https://myteam.cloudflareaccess.com" + + def test_certs_url_property_empty(self): + """Test certs_url property with empty domain.""" + settings = CloudflareSettings() + + assert settings.certs_url == "" + + def test_certs_url_property_with_domain(self): + """Test certs_url property with domain.""" + settings = CloudflareSettings( + cloudflare_team_domain="myteam.cloudflareaccess.com" + ) + + assert ( + settings.certs_url + == "https://myteam.cloudflareaccess.com/cdn-cgi/access/certs" + ) + + def test_parse_comma_separated_domains(self): + """Test parsing comma-separated email domains.""" + settings = CloudflareSettings(allowed_email_domains="example.com,company.com") + + assert settings.allowed_email_domains == ["example.com", "company.com"] + + def test_parse_comma_separated_empty(self): + """Test parsing empty comma-separated string.""" + settings = CloudflareSettings(allowed_email_domains="") + + assert settings.allowed_email_domains == [] + + def test_parse_comma_separated_list(self): + """Test that list input is preserved.""" + settings = CloudflareSettings( + allowed_email_domains=["example.com", "company.com"] + ) + + assert settings.allowed_email_domains == ["example.com", "company.com"] + + def test_is_email_allowed_no_restrictions(self): + """Test is_email_allowed with no domain restrictions.""" + settings = CloudflareSettings() + + assert settings.is_email_allowed("anyone@anywhere.com") is True + + def test_is_email_allowed_with_restrictions(self): + """Test is_email_allowed with domain restrictions.""" + settings = CloudflareSettings(allowed_email_domains=["company.com"]) + + assert settings.is_email_allowed("user@company.com") is True + assert settings.is_email_allowed("user@other.com") is False + + def test_is_email_allowed_case_insensitive(self): + """Test is_email_allowed is case insensitive.""" + settings = CloudflareSettings(allowed_email_domains=["Company.Com"]) + + assert settings.is_email_allowed("user@company.com") is True + assert settings.is_email_allowed("user@COMPANY.COM") is True + + def test_is_email_allowed_invalid_email(self): + """Test is_email_allowed with invalid email format.""" + settings = CloudflareSettings(allowed_email_domains=["company.com"]) + + assert settings.is_email_allowed("invalid-no-at-sign") is False + + @patch.dict(os.environ, {"CLOUDFLARE_TEAM_DOMAIN": "env-team.cloudflareaccess.com"}) + def test_from_environment(self): + """Test loading settings from environment.""" + settings = CloudflareSettings() + + assert settings.cloudflare_team_domain == "env-team.cloudflareaccess.com" + + +class TestGetCloudflareSettings: + """Test suite for get_cloudflare_settings function.""" + + def teardown_method(self): + """Reset settings after each test.""" + reset_settings() + + def test_returns_settings_instance(self): + """Test that get_cloudflare_settings returns a CloudflareSettings instance.""" + settings = get_cloudflare_settings() + + assert isinstance(settings, CloudflareSettings) + + def test_returns_singleton(self): + """Test that get_cloudflare_settings returns the same instance.""" + settings1 = get_cloudflare_settings() + settings2 = get_cloudflare_settings() + + assert settings1 is settings2 + + def test_reset_creates_new_instance(self): + """Test that reset_settings allows new instance creation.""" + settings1 = get_cloudflare_settings() + reset_settings() + settings2 = get_cloudflare_settings() + + # They should be different objects after reset + assert settings1 is not settings2 + + +class TestResetSettings: + """Test suite for reset_settings function.""" + + def test_reset_clears_singleton(self): + """Test that reset_settings clears the singleton.""" + # Get initial settings + _ = get_cloudflare_settings() + + # Reset + reset_settings() + + # Get new settings - should create new instance + settings = get_cloudflare_settings() + assert isinstance(settings, CloudflareSettings) diff --git a/packages/cloudflare-auth/tests/test_whitelist.py b/packages/cloudflare-auth/tests/test_whitelist.py new file mode 100644 index 0000000..3417a8d --- /dev/null +++ b/packages/cloudflare-auth/tests/test_whitelist.py @@ -0,0 +1,320 @@ +"""Tests for cloudflare_auth whitelist module.""" + +import pytest + +from cloudflare_auth.whitelist import ( + EmailWhitelistConfig, + EmailWhitelistValidator, + UserTier, + WhitelistManager, + create_validator_from_env, +) + + +class TestUserTier: + """Test suite for UserTier enum.""" + + def test_from_string_admin(self): + """Test creating ADMIN tier from string.""" + tier = UserTier.from_string("admin") + assert tier == UserTier.ADMIN + + def test_from_string_full(self): + """Test creating FULL tier from string.""" + tier = UserTier.from_string("full") + assert tier == UserTier.FULL + + def test_from_string_limited(self): + """Test creating LIMITED tier from string.""" + tier = UserTier.from_string("limited") + assert tier == UserTier.LIMITED + + def test_from_string_case_insensitive(self): + """Test that from_string is case insensitive.""" + assert UserTier.from_string("ADMIN") == UserTier.ADMIN + assert UserTier.from_string("Full") == UserTier.FULL + assert UserTier.from_string("LIMITED") == UserTier.LIMITED + + def test_from_string_invalid(self): + """Test that invalid tier raises ValueError.""" + with pytest.raises(ValueError, match="Invalid user tier"): + UserTier.from_string("invalid") + + def test_can_access_premium_models_admin(self): + """Test admin can access premium models.""" + assert UserTier.ADMIN.can_access_premium_models is True + + def test_can_access_premium_models_full(self): + """Test full tier can access premium models.""" + assert UserTier.FULL.can_access_premium_models is True + + def test_can_access_premium_models_limited(self): + """Test limited tier cannot access premium models.""" + assert UserTier.LIMITED.can_access_premium_models is False + + def test_has_admin_privileges(self): + """Test has_admin_privileges property.""" + assert UserTier.ADMIN.has_admin_privileges is True + assert UserTier.FULL.has_admin_privileges is False + assert UserTier.LIMITED.has_admin_privileges is False + + +class TestEmailWhitelistConfig: + """Test suite for EmailWhitelistConfig.""" + + def test_default_values(self): + """Test default configuration values.""" + config = EmailWhitelistConfig() + + assert config.whitelist == [] + assert config.admin_emails == [] + assert config.full_users == [] + assert config.limited_users == [] + assert config.case_sensitive is False + + def test_normalize_emails_from_string(self): + """Test normalizing emails from comma-separated string.""" + config = EmailWhitelistConfig( + whitelist="user1@example.com, user2@example.com, @company.com" + ) + + assert len(config.whitelist) == 3 + assert "user1@example.com" in config.whitelist + assert "user2@example.com" in config.whitelist + assert "@company.com" in config.whitelist + + def test_normalize_emails_from_list(self): + """Test normalizing emails from list.""" + config = EmailWhitelistConfig( + whitelist=["User1@Example.com", "User2@Example.com"] + ) + + assert "user1@example.com" in config.whitelist + assert "user2@example.com" in config.whitelist + + +class TestEmailWhitelistValidator: + """Test suite for EmailWhitelistValidator.""" + + @pytest.fixture + def validator(self): + """Create a sample validator for testing.""" + return EmailWhitelistValidator( + whitelist=["user@example.com", "@company.com"], + admin_emails=["admin@company.com"], + full_users=["@company.com"], + limited_users=["contractor@external.com"], + ) + + def test_is_authorized_individual_email(self, validator): + """Test authorization for individual email.""" + assert validator.is_authorized("user@example.com") is True + + def test_is_authorized_domain_pattern(self, validator): + """Test authorization for domain pattern.""" + assert validator.is_authorized("anyone@company.com") is True + assert validator.is_authorized("newuser@company.com") is True + + def test_is_authorized_unauthorized_email(self, validator): + """Test that unauthorized emails are rejected.""" + assert validator.is_authorized("unknown@other.com") is False + + def test_is_authorized_empty_email(self, validator): + """Test that empty email is not authorized.""" + assert validator.is_authorized("") is False + assert validator.is_authorized(None) is False + + def test_is_authorized_case_insensitive(self): + """Test case insensitive matching.""" + validator = EmailWhitelistValidator( + whitelist=["User@Example.com"], + case_sensitive=False, + ) + + assert validator.is_authorized("user@example.com") is True + assert validator.is_authorized("USER@EXAMPLE.COM") is True + + def test_is_admin(self, validator): + """Test admin detection.""" + assert validator.is_admin("admin@company.com") is True + assert validator.is_admin("user@example.com") is False + assert validator.is_admin("random@company.com") is False + + def test_is_admin_empty_email(self, validator): + """Test that empty email is not admin.""" + assert validator.is_admin("") is False + + def test_get_user_role(self, validator): + """Test get_user_role method.""" + assert validator.get_user_role("admin@company.com") == "admin" + assert validator.get_user_role("user@example.com") == "user" + assert validator.get_user_role("unknown@other.com") == "unauthorized" + + def test_get_user_tier_admin(self, validator): + """Test tier detection for admin.""" + tier = validator.get_user_tier("admin@company.com") + assert tier == UserTier.ADMIN + + def test_get_user_tier_full(self, validator): + """Test tier detection for full users.""" + tier = validator.get_user_tier("someone@company.com") + assert tier == UserTier.FULL + + def test_get_user_tier_limited(self, validator): + """Test tier detection for limited users.""" + validator2 = EmailWhitelistValidator( + whitelist=["contractor@external.com"], + limited_users=["contractor@external.com"], + ) + tier = validator2.get_user_tier("contractor@external.com") + assert tier == UserTier.LIMITED + + def test_get_user_tier_unauthorized(self, validator): + """Test tier detection for unauthorized email.""" + with pytest.raises(ValueError, match="not authorized"): + validator.get_user_tier("unknown@other.com") + + def test_get_user_tier_empty_email(self, validator): + """Test tier detection for empty email.""" + with pytest.raises(ValueError, match="cannot be empty"): + validator.get_user_tier("") + + def test_can_access_premium_models(self, validator): + """Test premium model access checking.""" + assert validator.can_access_premium_models("admin@company.com") is True + assert validator.can_access_premium_models("someone@company.com") is True + assert validator.can_access_premium_models("unknown@other.com") is False + + def test_has_admin_privileges(self, validator): + """Test admin privilege checking.""" + assert validator.has_admin_privileges("admin@company.com") is True + assert validator.has_admin_privileges("someone@company.com") is False + + def test_get_whitelist_stats(self, validator): + """Test whitelist statistics.""" + stats = validator.get_whitelist_stats() + + assert "individual_emails" in stats + assert "domain_patterns" in stats + assert "admin_emails" in stats + assert "total_entries" in stats + assert "tier_distribution" in stats + + def test_validate_whitelist_config_warnings(self): + """Test configuration validation warnings.""" + validator = EmailWhitelistValidator( + whitelist=["@company.com"], + admin_emails=["admin@other.com"], # Not in whitelist + ) + warnings = validator.validate_whitelist_config() + + assert len(warnings) > 0 + assert any("not in whitelist" in w for w in warnings) + + def test_validate_whitelist_config_public_domains(self): + """Test warning for public email domains.""" + validator = EmailWhitelistValidator( + whitelist=["@gmail.com"], + ) + warnings = validator.validate_whitelist_config() + + assert any("gmail.com" in w for w in warnings) + + +class TestWhitelistManager: + """Test suite for WhitelistManager.""" + + @pytest.fixture + def manager(self): + """Create a manager with a sample validator.""" + validator = EmailWhitelistValidator( + whitelist=["user@example.com"], + ) + return WhitelistManager(validator) + + def test_add_email(self, manager): + """Test adding email to whitelist.""" + result = manager.add_email("newuser@test.com") + + assert result is True + assert manager.validator.is_authorized("newuser@test.com") is True + + def test_add_domain_pattern(self, manager): + """Test adding domain pattern.""" + result = manager.add_email("@newdomain.com") + + assert result is True + assert manager.validator.is_authorized("anyone@newdomain.com") is True + + def test_add_email_as_admin(self, manager): + """Test adding email with admin privileges.""" + result = manager.add_email("admin@test.com", is_admin=True) + + assert result is True + assert manager.validator.is_admin("admin@test.com") is True + + def test_add_email_invalid_format(self, manager): + """Test adding invalid email format.""" + with pytest.raises(ValueError, match="Invalid email format"): + manager.add_email("invalid-email") + + def test_add_email_empty(self, manager): + """Test adding empty email.""" + with pytest.raises(ValueError, match="cannot be empty"): + manager.add_email("") + + def test_remove_email(self, manager): + """Test removing email from whitelist.""" + result = manager.remove_email("user@example.com") + + assert result is True + assert manager.validator.is_authorized("user@example.com") is False + + def test_remove_email_not_found(self, manager): + """Test removing non-existent email.""" + result = manager.remove_email("nonexistent@test.com") + + assert result is False + + def test_check_email(self, manager): + """Test checking email status.""" + status = manager.check_email("user@example.com") + + assert status["is_authorized"] is True + assert "email" in status + assert "is_admin" in status + assert "role" in status + + +class TestCreateValidatorFromEnv: + """Test suite for create_validator_from_env function.""" + + def test_create_from_comma_separated(self): + """Test creating validator from comma-separated strings.""" + validator = create_validator_from_env( + whitelist_str="user@example.com,@company.com", + admin_emails_str="admin@company.com", + full_users_str="@company.com", + ) + + assert validator.is_authorized("user@example.com") is True + assert validator.is_authorized("anyone@company.com") is True + assert validator.is_admin("admin@company.com") is True + + def test_create_with_empty_strings(self): + """Test creating validator with empty strings.""" + validator = create_validator_from_env( + whitelist_str="", + admin_emails_str="", + ) + + assert validator.is_authorized("anyone@test.com") is False + + def test_create_with_whitespace(self): + """Test handling of whitespace in input.""" + validator = create_validator_from_env( + whitelist_str=" user@example.com , @company.com ", + ) + + assert validator.is_authorized("user@example.com") is True + assert validator.is_authorized("anyone@company.com") is True diff --git a/packages/gemini-image/src/gemini_image/cli.py b/packages/gemini-image/src/gemini_image/cli.py index 74ee5c2..e6d7868 100644 --- a/packages/gemini-image/src/gemini_image/cli.py +++ b/packages/gemini-image/src/gemini_image/cli.py @@ -16,13 +16,13 @@ def list_models() -> None: """Print available models.""" - print("Available models:\n") # noqa: T201 + print("Available models:\n") for key, config in MODELS.items(): - print(f" {key}:") # noqa: T201 - print(f" Name: {config['name']}") # noqa: T201 - print(f" ID: {config['id']}") # noqa: T201 - print(f" Description: {config['description']}") # noqa: T201 - print() # noqa: T201 + print(f" {key}:") + print(f" Name: {config['name']}") + print(f" ID: {config['id']}") + print(f" Description: {config['description']}") + print() def main() -> None: @@ -171,7 +171,7 @@ def main() -> None: ) sys.exit(0 if result else 1) except FileNotFoundError as e: - print(f"Error: {e}") # noqa: T201 + print(f"Error: {e}") sys.exit(1) if not args.prompt: @@ -181,7 +181,7 @@ def main() -> None: # Story sequence mode if args.story_parts: if args.story_parts < 2: - print("Error: Story must have at least 2 parts") # noqa: T201 + print("Error: Story must have at least 2 parts") sys.exit(1) results = generate_story_sequence( @@ -214,15 +214,15 @@ def main() -> None: ) if result and args.draft_mode: - print(f"\n{'=' * 60}") # noqa: T201 - print("Draft complete! To finalize at higher resolution:") # noqa: T201 - print(f" gemini-image --finalize {result} --size 2K") # noqa: T201 - print(f"{'=' * 60}") # noqa: T201 + print(f"\n{'=' * 60}") + print("Draft complete! To finalize at higher resolution:") + print(f" gemini-image --finalize {result} --size 2K") + print(f"{'=' * 60}") sys.exit(0 if result else 1) except (ValueError, ImportError) as e: - print(f"Error: {e}") # noqa: T201 + print(f"Error: {e}") sys.exit(1) diff --git a/packages/gemini-image/tests/test_generator.py b/packages/gemini-image/tests/test_generator.py index 289dffb..8c6d027 100644 --- a/packages/gemini-image/tests/test_generator.py +++ b/packages/gemini-image/tests/test_generator.py @@ -1,6 +1,5 @@ """Tests for image generation functions.""" -# ruff: noqa: S101 # Bandit B101 (assert_used) is expected in test files - pytest uses assert statements from __future__ import annotations diff --git a/packages/gemini-image/tests/test_models.py b/packages/gemini-image/tests/test_models.py index 6c0cea7..6fac8a8 100644 --- a/packages/gemini-image/tests/test_models.py +++ b/packages/gemini-image/tests/test_models.py @@ -1,6 +1,5 @@ """Tests for model configurations.""" -# ruff: noqa: S101 # Bandit B101 (assert_used) is expected in test files - pytest uses assert statements from gemini_image.models import ( diff --git a/packages/gemini-image/tests/test_utils.py b/packages/gemini-image/tests/test_utils.py index 2a592de..b8b990c 100644 --- a/packages/gemini-image/tests/test_utils.py +++ b/packages/gemini-image/tests/test_utils.py @@ -1,6 +1,5 @@ """Tests for utility functions.""" -# ruff: noqa: S101 # Bandit B101 (assert_used) is expected in test files - pytest uses assert statements from __future__ import annotations diff --git a/pyproject.toml b/pyproject.toml index fc41129..8039462 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -263,6 +263,9 @@ ignore = [ "FBT001", # Boolean positional arg (acceptable in DSLs) "FBT002", # Boolean default arg (acceptable in DSLs) "TRY003", # Long exception messages (descriptive is good) + + # Python 3.10 compatibility (packages support Python 3.10+) + "UP017", # Use datetime.UTC alias (not available in Python 3.10) ] # McCabe Complexity Configuration (Google/Pylint alignment) @@ -309,6 +312,31 @@ known-first-party = ["python_libs", "cloudflare_auth", "gcs_utilities", "gemini_ "S113", # request_without_timeout - test HTTP calls may be mocked ] +# Package test files (same relaxed rules as above) +"packages/*/tests/**/*.py" = [ + "ANN", # Type annotations not required in tests + "ARG", # Unused arguments (fixtures) + "D", # Docstrings not required in tests + "PLR", # Pylint refactor suggestions + "PLC0415", # Imports inside test functions are idiomatic + "ERA", # Commented code (test debugging) + "SLF", # Private member access (testing internals) + "BLE", # Blind except (test error handling) + "EM", # Error messages (test exceptions) + "TRY", # Exception handling (test assertions) + "INP", # No __init__.py required for test directories + "PT011", # pytest.raises match parameter (not always needed) + "F841", # Unused variables (test setup/teardown) + # Security rules OK to skip in tests (specific, not blanket S) + "S101", # assert_used - pytest uses asserts + "S105", # hardcoded_password_string - test credentials are fake + "S106", # hardcoded_password_funcarg - test fixtures with fake passwords + "S107", # hardcoded_password_default - test default values + "S108", # hardcoded_tmp_directory - test temp paths are intentional + "S311", # random - non-crypto random in tests is fine + "S113", # request_without_timeout - test HTTP calls may be mocked +] + # Scripts (utility code - not packages) "scripts/**/*.py" = [ "ANN", "D", "T20", "TRY", "PTH", "PLR", @@ -330,6 +358,11 @@ known-first-party = ["python_libs", "cloudflare_auth", "gcs_utilities", "gemini_ "S607", # start_process_with_partial_path - scripts use PATH ] +# CLI files (command-line interfaces use print for output) +"**/cli.py" = [ + "T20", # Print statements are expected in CLI tools +] + # Benchmarks (performance testing) "benchmarks/**/*.py" = [ "ANN", "D", "T20", "ARG", "TRY", "PLR", diff --git a/sonar-project.properties b/sonar-project.properties index 5941364..cbe910f 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -13,10 +13,10 @@ sonar.projectVersion=0.1.0 # Source Code Configuration # ============================================================================= # Source directories (comma-separated) -sonar.sources=src/ +sonar.sources=src/,packages/ # Test directories (comma-separated) -sonar.tests=tests/ +sonar.tests=tests/,packages/*/tests/ # Python version sonar.python.version=3.12 @@ -88,6 +88,28 @@ sonar.issue.ignore.multicriteria.e2.resourceKey=**/tests/** sonar.issue.ignore.multicriteria.e3.ruleKey=python:S107 sonar.issue.ignore.multicriteria.e3.resourceKey=**/conftest.py +# ============================================================================= +# Security Hotspot Exclusions for Test Files +# ============================================================================= +# Test files intentionally use hardcoded IPs, HTTP URLs, etc. for testing +sonar.issue.ignore.multicriteria=e1,e2,e3,e4,e5,e6,e7 + +# Ignore hardcoded IP addresses in test files (S1313) +sonar.issue.ignore.multicriteria.e4.ruleKey=python:S1313 +sonar.issue.ignore.multicriteria.e4.resourceKey=**/tests/** + +# Ignore HTTP protocol warnings in test files (S5332) +sonar.issue.ignore.multicriteria.e5.ruleKey=python:S5332 +sonar.issue.ignore.multicriteria.e5.resourceKey=**/tests/** + +# Ignore hardcoded credentials in test files (S2068) +sonar.issue.ignore.multicriteria.e6.ruleKey=python:S2068 +sonar.issue.ignore.multicriteria.e6.resourceKey=**/tests/** + +# Ignore hardcoded secrets in test files (S6418) +sonar.issue.ignore.multicriteria.e7.ruleKey=python:S6418 +sonar.issue.ignore.multicriteria.e7.resourceKey=**/tests/** + # ============================================================================= # Additional Linter Reports (Optional) # ============================================================================= diff --git a/tests/test_example.py b/tests/test_example.py index 56fc771..0e66da1 100644 --- a/tests/test_example.py +++ b/tests/test_example.py @@ -8,7 +8,6 @@ - Docstring examples that can be tested with doctest """ -# ruff: noqa: S101 # Bandit B101 (assert_used) is expected in test files - pytest uses assert statements import pytest diff --git a/uv.lock b/uv.lock index 652a405..cdbaef7 100644 --- a/uv.lock +++ b/uv.lock @@ -10,6 +10,7 @@ resolution-markers = [ [manifest] members = [ + "byronwilliamscpa-cloudflare-api", "byronwilliamscpa-cloudflare-auth", "byronwilliamscpa-gcs-utilities", "byronwilliamscpa-gemini-image", @@ -319,6 +320,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" }, ] +[[package]] +name = "byronwilliamscpa-cloudflare-api" +version = "0.1.0" +source = { editable = "packages/cloudflare-api" } +dependencies = [ + { name = "cloudflare" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyyaml" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "respx" }, +] + +[package.metadata] +requires-dist = [ + { name = "cloudflare", specifier = ">=4.0.0" }, + { name = "httpx", specifier = ">=0.25.0" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pydantic-settings", specifier = ">=2.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, + { name = "pyyaml", specifier = ">=6.0.0" }, + { name = "respx", marker = "extra == 'dev'", specifier = ">=0.21.0" }, +] +provides-extras = ["dev"] + [[package]] name = "byronwilliamscpa-cloudflare-auth" version = "0.1.0" @@ -326,7 +361,7 @@ source = { editable = "packages/cloudflare-auth" } dependencies = [ { name = "cryptography" }, { name = "httpx" }, - { name = "pydantic" }, + { name = "pydantic", extra = ["email"] }, { name = "pydantic-settings" }, { name = "pyjwt" }, ] @@ -356,7 +391,7 @@ requires-dist = [ { name = "fastapi", marker = "extra == 'all'", specifier = ">=0.100.0" }, { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.100.0" }, { name = "httpx", specifier = ">=0.25.0" }, - { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.0.0" }, { name = "pydantic-settings", specifier = ">=2.0.0" }, { name = "pyjwt", specifier = ">=2.8.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" }, @@ -651,6 +686,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] +[[package]] +name = "cloudflare" +version = "4.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/48/e481c0a9b9010a5c41b5ca78ff9fbe00dc8a9a4d39da5af610a4ec49c7f7/cloudflare-4.3.1.tar.gz", hash = "sha256:b1e1c6beeb8d98f63bfe0a1cba874fc4e22e000bcc490544f956c689b3b5b258", size = 1933187, upload-time = "2025-06-16T21:43:18.716Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/8f/c6c543565efd3144da4304efa5917aac06b6416a8663a6defe0e9b2b7569/cloudflare-4.3.1-py3-none-any.whl", hash = "sha256:6927135a5ee5633d6e2e1952ca0484745e933727aeeb189996d2ad9d292071c6", size = 4406465, upload-time = "2025-06-16T21:43:17.3Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -2751,6 +2803,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, ] +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + [[package]] name = "pydantic-core" version = "2.41.5" @@ -3445,6 +3502,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "respx" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" }, +] + [[package]] name = "rfc3339-validator" version = "0.1.4"