# IGNY8 ↔ WordPress Integration Security Plan

**Date:** 2026-01-13  
**Status:** Audit Complete - Plan Ready

---

## PART 1: ACTUAL ARCHITECTURE (VERIFIED)

### 1.1 Publishing Flow (WORKING - No Changes Needed)

```
Frontend (Review/Approved pages)
         │
         ▼
POST /v1/publisher/publish/
         │
         ▼
PublisherService._publish_to_destination()
         │
         ├── Gets API key: content.site.wp_api_key (SINGLE SOURCE OF TRUTH)
         ├── Gets URL: content.site.domain or content.site.url
         │
         ▼
WordPressAdapter._publish_via_api_key()
         │
         ▼
POST {site_url}/wp-json/igny8/v1/publish
    Headers: X-IGNY8-API-KEY: {api_key}
         │
         ▼
Plugin: check_permission() validates header against stored igny8_api_key
         │
         ▼
Plugin creates/updates WordPress post
```

**Key Finding:** Publishing does NOT use SiteIntegration model. It uses:
- `Site.wp_api_key` - for authentication
- `Site.domain` - for WordPress URL

### 1.2 Data Storage Locations

| Location | Field | Purpose | Used By |
|----------|-------|---------|---------|
| **Django Site model** | `wp_api_key` | API key (source of truth) | Publishing, test-connection |
| **Django Site model** | `domain` | WordPress URL | Publishing |
| **WordPress wp_options** | `igny8_api_key` | Stored copy of API key | Plugin auth check |
| **WordPress wp_options** | `igny8_site_id` | Site ID from key | Plugin display |
| **WordPress wp_options** | `igny8_integration_id` | Integration ID | Plugin (currently unused) |
| **WordPress wp_options** | `igny8_last_structure_sync` | Last sync timestamp | Connection status UI |

### 1.3 SiteIntegration Model Status

**Table exists:** `igny8_site_integrations`  
**Records:** 0 (empty)  
**Used by:** 
- `publishing_scheduler.py` - scheduled publishing (requires record)
- `wordpress_publishing.py` - Celery task path (requires record)
- `sync_metadata_service.py` - metadata sync (requires record)
- `ContentViewSet.publish` action (requires record)

**NOT used by:**
- `PublisherService` (main UI publishing path) - uses Site directly
- `WordPressAdapter` - uses Site.wp_api_key directly

---

## PART 2: SECURITY ISSUES FOUND

### 2.1 Plugin Public Endpoints (SECURITY RISK)

| Endpoint | Current Permission | Risk | Data Exposed |
|----------|-------------------|------|--------------|
| `/igny8/v1/status` | `__return_true` (PUBLIC) | Medium | Plugin installed, has_api_key, plugin version |
| `/igny8/v1/site-metadata/` | `__return_true` (PUBLIC*) | Low | Post types, taxonomies (auth checked inside) |

*Note: `/site-metadata/` checks permission inside callback but still registers as public

### 2.2 Backend test-connection Flow

The `test_connection_collection` endpoint:
1. Uses `AllowAny` permission class
2. BUT validates authentication inside the handler
3. Calls public `/status` endpoint to check if plugin installed
4. Then calls authenticated `/verify-key` endpoint

**Issue:** Relies on public `/status` endpoint to detect plugin presence.

---

## PART 3: RECOMMENDED CHANGES

### 3.1 Plugin Changes (REQUIRED)

#### 3.1.1 Make `/status` Endpoint Authenticated

**File:** `plugins/wordpress/source/igny8-wp-bridge/includes/class-igny8-rest-api.php`

**Current (lines 83-87):**
```php
register_rest_route('igny8/v1', '/status', array(
    'methods' => 'GET',
    'callback' => array($this, 'get_status'),
    'permission_callback' => '__return_true', // Public endpoint for health checks
));
```

**Change to:**
```php
register_rest_route('igny8/v1', '/status', array(
    'methods' => 'GET',
    'callback' => array($this, 'get_status'),
    'permission_callback' => array($this, 'check_api_key_auth'),
));
```

#### 3.1.2 Add Lightweight Auth Check Method

Add new method to allow API key OR no key if not configured yet:

```php
/**
 * Check API key authentication for status-type endpoints
 * Returns true if: no API key stored yet, OR valid API key provided
 */
public function check_api_key_auth($request) {
    $stored_api_key = function_exists('igny8_get_secure_option') 
        ? igny8_get_secure_option('igny8_api_key') 
        : get_option('igny8_api_key');
    
    // If no API key configured yet, allow access (plugin not connected)
    if (empty($stored_api_key)) {
        return true;
    }
    
    // If API key is configured, require valid key in header
    $header_api_key = $request->get_header('x-igny8-api-key');
    if ($header_api_key && hash_equals($stored_api_key, $header_api_key)) {
        return true;
    }
    
    return new WP_Error('rest_forbidden', 'Invalid API key', array('status' => 401));
}
```

#### 3.1.3 Fix `/site-metadata/` Route Registration

**Current (lines 74-79):**
```php
register_rest_route('igny8/v1', '/site-metadata/', array(
    'methods' => 'GET',
    'callback' => array($this, 'get_site_metadata'),
    'permission_callback' => '__return_true',
));
```

**Change to:**
```php
register_rest_route('igny8/v1', '/site-metadata/', array(
    'methods' => 'GET',
    'callback' => array($this, 'get_site_metadata'),
    'permission_callback' => array($this, 'check_permission'),
));
```

Then remove the internal permission check from `get_site_metadata()` method.

### 3.2 Backend Changes (REQUIRED)

#### 3.2.1 Update test-connection to Not Rely on Public Endpoint

**File:** `backend/igny8_core/modules/integration/views.py`

**Current approach:**
1. Call `/status` (public) to check plugin installed
2. Call `/verify-key` (authenticated) to verify key

**New approach:**
1. Call `/verify-key` directly
2. If 200 → plugin installed AND key valid
3. If 404 → plugin not installed (route doesn't exist)
4. If 401/403 → plugin installed but key mismatch

**Change Check 2 section (approximately lines 218-237):**

```python
# Check 2 & 3 Combined: Plugin installed + API key verification
# Use /verify-key endpoint - if it succeeds, both are confirmed
try:
    verify_response = http_requests.get(
        f"{site_url.rstrip('/')}/wp-json/igny8/v1/verify-key",
        headers={
            'X-IGNY8-API-KEY': stored_api_key,
            'Content-Type': 'application/json'
        },
        timeout=10
    )
    if verify_response.status_code == 200:
        health_checks['plugin_installed'] = True
        health_checks['plugin_has_api_key'] = True
        health_checks['api_key_verified'] = True
    elif verify_response.status_code == 404:
        # Route not found = plugin not installed
        issues.append("IGNY8 plugin not installed on WordPress site")
    elif verify_response.status_code in [401, 403]:
        # Auth failed = plugin installed but key doesn't match
        health_checks['plugin_installed'] = True
        issues.append("API key mismatch - copy the API key from IGNY8 to WordPress plugin settings")
    else:
        issues.append(f"Unexpected response from plugin: HTTP {verify_response.status_code}")
except http_requests.exceptions.ConnectionError:
    issues.append("Cannot connect to WordPress site")
except Exception as e:
    issues.append(f"Connection error: {str(e)}")
```

### 3.3 SiteIntegration Decision (OPTIONAL)

The SiteIntegration model is used by:
- Scheduled publishing task
- ContentViewSet.publish action  
- Metadata sync service

**Options:**

**Option A: Remove SiteIntegration entirely**
- Modify scheduled publishing to use Site directly
- Modify sync service to use Site directly
- Remove model and migrations
- **Effort:** High, risk of breaking things

**Option B: Keep but don't require it**
- Current main publishing path works without it
- Scheduled publishing would need records created
- **Effort:** Low, minimal changes

**Recommendation:** Option B - Keep as-is. The main UI publishing flow works. If scheduled publishing is needed, run:
```bash
docker exec igny8_backend python manage.py sync_wordpress_api_keys
```

---

## PART 4: IMPLEMENTATION ORDER

### Phase 1: Plugin Security (30 min)
1. Add `check_api_key_auth()` method to plugin
2. Change `/status` endpoint to use `check_api_key_auth`
3. Change `/site-metadata/` to use `check_permission`
4. Remove internal permission check from `get_site_metadata()`
5. Build and deploy plugin

### Phase 2: Backend Update (15 min)
1. Update `test_connection_collection` to use only `/verify-key`
2. Remove `/status` endpoint call
3. Test connection flow

### Phase 3: Testing (15 min)
1. Test plugin uninstalled scenario (404 from /verify-key)
2. Test plugin installed, no key (401 from /verify-key)
3. Test plugin installed, wrong key (401 from /verify-key)
4. Test plugin installed, correct key (200 from /verify-key)
5. Test publishing still works

---

## PART 5: FILES TO MODIFY

### Plugin Files
| File | Changes |
|------|---------|
| `includes/class-igny8-rest-api.php` | Add `check_api_key_auth()`, update route permissions |

### Backend Files
| File | Changes |
|------|---------|
| `modules/integration/views.py` | Update `test_connection_collection` logic |

---

## PART 6: WHAT STAYS THE SAME

✅ **Publishing flow** - No changes needed  
✅ **Site.wp_api_key** - Single source of truth  
✅ **Site.domain** - WordPress URL source  
✅ **WordPressAdapter** - Works correctly  
✅ **Plugin /publish endpoint** - Already authenticated  
✅ **Plugin /verify-key endpoint** - Already authenticated  
✅ **SiteIntegration model** - Keep for scheduled publishing (optional use)

---

## Document History
| Date | Author | Changes |
|------|--------|---------|
| 2026-01-13 | AI | Created after proper code audit |
