# Single Source of Truth (SSOT) Architecture Guide

## Overview

This guide documents the **HYBRID CONTROL PLANE REFLECTION PATTERN** used in this two-application SaaS architecture.

### Single Source of Truth (SSOT)

**Admin App (Control Plane)** is the **ONLY** system allowed to write tenant control data.

**Tenant App (Data Plane)** holds **READ-ONLY** projections of Admin App data.

```
┌─────────────────────────────────────────────────────────────────────┐
│                    ADMIN APP (CONTROL PLANE)                  │
│  /home/shakib/Dev/projects/saas-admin-docker/app/         │
│                                                             │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │  taskco_saas_admin Database (AUTHORITATIVE)      │   │
│  │  ✓ tenants table                                        │   │
│  │  ✓ domains table                                       │   │
│  │  ✓ subscriptions table                                   │   │
│  │  ✓ packages table                                       │   │
│  │  ✓ billing tables                                       │   │
│  │                                                         │   │
│  │  ALL MUTATIONS HERE (WRITE)                             │   │
│  └─────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────┘
                          │
                          │ (READ-ONLY via API)
                          │
                          ▼
┌─────────────────────────────────────────────────────────────────────┐
│                    TENANT APP (DATA PLANE)                      │
│  /home/shakib/Dev/projects/saas-docker/app/               │
│                                                             │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │  taskco_saas_admin Database (PROJECTION - MIRROR)     │   │
│  │  ✗ NO MUTATIONS ALLOWED                               │   │
│  │  ✓ tenants table (READ-ONLY)                           │   │
│  │  ✓ domains table (READ-ONLY)                           │   │
│  │                                                         │   │
│  │  For READ-ONLY: Tenant resolution via stancl/tenancy  │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                             │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │  taskco-{slug} Tenant Databases                    │   │
│  │  ✓ Business data (customers, tasks, etc.)            │   │
│  │  ✓ Full write access                                  │   │
│  └─────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────┘
```

---

## 1. Single Source of Truth Enforcement (CRITICAL)

### 1.1 Laravel-Level Read-Only Enforcement

**Base Class:** `App\Models\Base\ReadOnlyModel`

Located at: `/home/shakib/Dev/projects/saas-docker/app/app/Models/Base/ReadOnlyModel.php`

This base class prevents ALL write operations:

```php
// Extending ReadOnlyModel prevents:
Tenant::create(...)           // ❌ throws LogicException
$tenant->save()             // ❌ throws LogicException
$tenant->update([...])        // ❌ throws LogicException
$tenant->delete()             // ❌ throws LogicException
Domain::insert([...])         // ❌ throws LogicException
```

**Exception Message:**
```
Cannot create/update/delete X record. This is a read-only projection from Admin App (Control Plane).
All mutations must go through Admin App API.
```

### 1.2 Model-Level Protection

**Tenant Model:** `/home/shakib/Dev/projects/saas-docker/app/app/Models/Admin/Tenant.php`

- Extends `Stancl\Tenancy\Database\Models\Tenant` (required by stancl/tenancy)
- Uses `central` database connection
- Has `boot()` method with write protection
- Overrides `save()`, `delete()` to throw exceptions

**Domain Model:** `/home/shakib/Dev/projects/saas-docker/app/app/Models/Admin/Domain.php`

- Extends `ReadOnlyModel`
- Uses `central` database connection
- All write methods blocked

### 1.3 DB-Level Read-Only Recommendations (For Production)

PostgreSQL can enforce read-only at the connection level:

**Option A: Read-Only User for Central Connection**

In `config/database.php` - central connection:

```php
'central' => [
    'driver' => 'pgsql',
    'host' => env('CENTRAL_DB_HOST', env('DB_HOST', '127.0.0.1')),
    'port' => env('CENTRAL_DB_PORT', env('DB_PORT', '5432')),
    'database' => env('CENTRAL_DB_DATABASE', 'taskco_saas_admin'),
    'username' => env('CENTRAL_DB_READ_ONLY_USERNAME', 'taskco_reader'), // Separate read-only user
    'password' => env('CENTRAL_DB_READ_ONLY_PASSWORD', '...'),
    // ...
],
```

**Option B: PostgreSQL REVOKE on Connection**

Run this SQL on Admin App database:

```sql
-- Create read-only role
CREATE ROLE taskco_app_reader NOINHERIT NOLOGIN;

-- Grant SELECT on projection tables
GRANT SELECT ON TABLE tenants TO taskco_app_reader;
GRANT SELECT ON TABLE domains TO taskco_app_reader;

-- Revoke all other permissions
REVOKE INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER
    ON TABLE tenants FROM taskco_app_reader;
REVOKE INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER
    ON TABLE domains FROM taskco_app_reader;
```

---

## 2. Tenant Resolution Flow (STRICT ORDER)

### Resolution Order: Redis → DB → API → FAIL

**Service:** `App\Services\TenantResolutionService`

Located at: `/home/shakib/Dev/projects/saas-docker/app/app/Services/TenantResolutionService.php`

### 2.1 Resolution Algorithm

```php
public function resolveByDomain(string $domain): ?Tenant
{
    // 1. REDIS (fastest path)
    $cached = $this->getFromCache($cacheKey);
    if ($cached !== null) {
        return $this->hydrateTenantFromCache($cached);
    }

    // 2. LOCAL READ-ONLY DB (fallback)
    $tenant = $this->getFromDatabaseByDomain($domain);
    if ($tenant !== null) {
        $this->setCache($cacheKey, $tenant); // Warm cache
        return $tenant;
    }

    // 3. ADMIN API (last resort)
    $tenant = $this->getFromAdminApiByDomain($domain);
    if ($tenant !== null) {
        $this->syncProjection($tenant); // Update projection
        $this->setCache($cacheKey, $tenant); // Warm cache
        return $tenant;
    }

    // 4. FAIL CLOSED - never guess
    return null;
}
```

### 2.2 Redis Key Strategy

**Prefix:** `tenant:resolution`

**Keys:**
- `tenant:resolution:by-domain:{md5(domain)}` → `{tenant_id, slug, status}`
- `tenant:resolution:by-slug:{slug}` → `{tenant_id, domain, status}`
- `tenant:projection:{tenant_id}` → Full tenant object
- `tenant:resolution:primary-domain:{tenant_id}` → `{domain_id, domain}`

**TTL:** 1 hour (configurable via `SAAS_ADMIN_RESOLUTION_CACHE_TTL`)

### 2.3 Example Resolution Flow

```
Request: demo.localhost

├─ 1. Check Redis: tenant:resolution:by-domain:md5("demo.localhost")
│   ├─ HIT: Return tenant ID, warm cache
│   └─ MISS: Continue to step 2
│
├─ 2. Query DB: SELECT * FROM domains WHERE domain = 'demo.localhost'
│   ├─ FOUND: Return tenant, write to Redis
│   └─ NOT FOUND: Continue to step 3
│
├─ 3. Call Admin API: GET /api/internal/tenants/resolve/domain?domain=demo.localhost
│   ├─ SUCCESS: Sync projection, write to Redis, return tenant
│   └─ 404: Return null (fail closed)
│
└─ 4. FAIL CLOSED: Return null or 404 error
```

---

## 3. Synchronization Strategy

### 3.1 Event-Driven Sync (Primary Method)

**Admin App Events:**
- `TenantCreated` → Sends webhook to Tenant App
- `TenantUpdated` → Sends webhook to Tenant App
- `TenantDeleted` → Sends webhook to Tenant App
- `DomainCreated` → Sends webhook to Tenant App
- `DomainUpdated` → Sends webhook to Tenant App
- `DomainDeleted` → Sends webhook to Tenant App

**Tenant App Endpoint:**
- `POST /api/internal/tenants/sync` (protected by JWT)

**Controller:** `App\Http\Controllers\Internal\TenantSyncController`

### 3.2 Webhook Payload Format

```json
{
  "event": "tenant.created",
  "tenant": {
    "id": 1,
    "uid": "550e8400-e29b-41d4-a716-446655440000",
    "company_name": "Demo Company",
    "slug": "demo",
    "database": "taskco-demo",
    "email": "admin@demo.com",
    "phone": "1234567890",
    "status": "active",
    "created_at": "2025-01-01T00:00:00Z",
    "updated_at": "2025-01-01T00:00:00Z"
  },
  "domains": [
    {
      "id": 1,
      "domain": "demo.localhost",
      "tenant_id": 1,
      "is_primary": true,
      "is_custom": false,
      "status": "active",
      "created_at": "2025-01-01T00:00:00Z",
      "updated_at": "2025-01-01T00:00:00Z"
    }
  ],
  "timestamp": "2025-01-01T00:00:00Z"
}
```

### 3.3 Cache Invalidation on Sync

When sync webhook is received:

```php
$this->tenantResolution->invalidateCache($tenantId, $slug, $domain);
```

**Invalidated Keys:**
- `tenant:resolution:by-domain:{md5(domain)}`
- `tenant:resolution:by-slug:{slug}`
- `tenant:projection:{tenant_id}`
- `tenant:resolution:primary-domain:{tenant_id}`

---

## 4. Manual Sync Command (Backup Method)

**Command:** `php artisan tenants:sync --from=admin`

**Artisan Command:** `App\Console\Commands\TenantsSyncCommand`

Located at: `/home/shakib/Dev/projects/saas-docker/app/app/Console/Commands/TenantsSyncCommand.php`

### 4.1 Usage

```bash
# Interactive sync
php artisan tenants:sync --from=admin

# Force sync without confirmation
php artisan tenants:sync --from=admin --force
```

### 4.2 Sync Process

1. **Fetch tenants from Admin App**
   - `GET /api/internal/tenants/projections/all`
   - Requires JWT with scope `tenant:sync`

2. **Update local projection**
   - Insert new tenants
   - Update existing tenants
   - Soft-delete tenants not in Admin App

3. **Fetch domains from Admin App**
   - `GET /api/internal/domains/projections/all`
   - Requires JWT with scope `tenant:sync`

4. **Update domain projection**
   - Insert new domains
   - Update existing domains
   - Delete domains not in Admin App

5. **Invalidate cache** for all affected tenants

### 4.3 Admin App API Endpoints (for Sync)

**Controller:** `App\Http\Controllers\Internal\TenantProjectionController`

Located at: `/home/shakib/Dev/projects/saas-admin-docker/app/app/Http/Controllers/Internal/TenantProjectionController.php`

| Endpoint | Method | Scope | Purpose |
|----------|--------|--------|---------|
| `/api/internal/tenants/resolve/domain` | GET | `tenant:resolve` | Resolve tenant by domain |
| `/api/internal/tenants/resolve/slug` | GET | `tenant:resolve` | Resolve tenant by slug |
| `/api/internal/tenants/{id}/primary-domain` | GET | `tenant:resolve` | Get primary domain |
| `/api/internal/tenants/projections/all` | GET | `tenant:sync` | Get all tenants |
| `/api/internal/domains/projections/all` | GET | `tenant:sync` | Get all domains |

---

## 5. Failure & Degraded Modes

### 5.1 When Admin API is Unavailable

**Behavior:**
- Redis cache continues working (if warm)
- Local DB projection continues working
- New tenant resolution returns null (fail closed)
- Existing tenants continue functioning

**Fallback:**
- Admin API timeout: 5 seconds
- Error logged with full stack trace
- Exception thrown to caller

### 5.2 When Redis Cache is Cold

**Behavior:**
- Fall through to DB projection
- DB query latency: ~10-50ms (typical)
- Cache warmed on first successful resolution

### 5.3 When Projection DB is Stale

**Behavior:**
- Admin API sync resolves with current data
- Projection updated via webhook or manual sync
- Cache invalidated and warmed with fresh data

### 5.4 Failure Rules (CRITICAL)

1. **NEVER GUESS DATA** - Return null instead of creating fake records
2. **PREFER DENIAL OVER CORRUPTION** - Better to return 404 than wrong tenant
3. **LOG ALL FAILURES** - Full error context logged
4. **ALWAYS VALIDATE RESPONSES** - Admin API responses validated before sync

---

## 6. Data Duplication Safety

### 6.1 Allowed to Duplicate

**Control Plane Metadata (Safe):**
- `tenant_id` - Unique identifier
- `domain` - For tenant resolution
- `status` - Active/inactive/suspended
- `plan_id` - For feature access
- `feature_flags` - For tenant configuration

**Why Safe:**
- Small datasets (<10,000 tenants typically)
- Updates are infrequent (minutes/hours)
- Data is required for fast resolution

### 6.2 Forbidden to Duplicate

**Business Data (Must Stay in Tenant DB):**
- Transactions - Store in tenant-specific DB
- Customer records - Store in tenant-specific DB
- Billing data - Store in Admin App only
- Tenant-specific settings - Store in tenant-specific DB

**Why Forbidden:**
- Large datasets (millions of rows)
- Frequent updates (seconds/minutes)
- PII/sensitive data
- Business logic requires consistency

### 6.3 Why This Duplication is Safe

```
┌─────────────────────────────────────────────────────────────┐
│  CONTROL PLANE (10-1000 tenants)                     │
│  - Writes: ~100/day                                    │
│  - Read: ~10,000/day (via API)                      │
│  - Data size: ~1MB                                     │
└─────────────────────────────────────────────────────────────┘

    Mirror (read-only projection)

┌─────────────────────────────────────────────────────────────┐
│  DATA PLANE PROJECTION (10-1000 tenants)               │
│  - Writes: 0/day (sync only)                          │
│  - Read: ~1,000,000/day (tenant resolution)          │
│  - Data size: ~1MB                                     │
│  - Caches in Redis: 1 hour TTL                         │
└─────────────────────────────────────────────────────────────┘
```

**Bandwidth Analysis:**
- Sync webhook: ~5KB per tenant update
- Projection reads: 10-50ms (cached from Redis)
- Net benefit: 99.9% of reads served from cache (estimated)

---

## 7. Security Requirements

### 7.1 Service-to-Service Authentication

**Admin App → Tenant App (Provisioning):**
- Uses RS256 JWT (asymmetric encryption)
- Public key: `SAAS_ADMIN_JWT_PUBLIC_KEY_PATH`
- Issuer: `saas-admin`
- Audience: `saas-app`
- Middleware: `VerifyServiceJwt`

**Tenant App → Admin App (Sync/Resolution):**
- Uses Bearer token (configurable)
- Token: `SAAS_ADMIN_API_TOKEN`
- Or can use client credentials flow (future enhancement)
- Middleware: `verify.service.jwt` (Admin App side)

### 7.2 JWT Middleware

**Tenant App:** `App\Http\Middleware\VerifyServiceJwt`

Located at: `/home/shakib/Dev/projects/saas-docker/app/app/Http/Middleware/VerifyServiceJwt.php`

**Security Checks:**
- RS256 signature verification
- Token expiration (with 30s clock skew)
- Issuer validation (`saas-admin`)
- Audience validation (`saas-app`)
- Scope-based access control
- Request ID audit logging

### 7.3 Network Security

**In Production:**
- HTTPS enforced on all API endpoints
- TLS 1.3 minimum
- Certificate pinning (recommended)
- API rate limiting (recommended)
- IP whitelisting for internal APIs

### 7.4 Auditable Admin Mutations

**All tenant mutations logged:**
- Who changed tenant data (admin_id)
- What was changed (before/after)
- When changed (timestamp)
- IP address
- User agent

**Audit Log Table (Admin App):**
```sql
CREATE TABLE tenant_audit_log (
    id BIGSERIAL PRIMARY KEY,
    tenant_id INTEGER NOT NULL,
    admin_id INTEGER NOT NULL,
    action VARCHAR(50) NOT NULL,
    old_data JSONB,
    new_data JSONB,
    ip_address INET,
    user_agent TEXT,
    created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
```

---

## 8. Configuration Guide

### 8.1 Tenant App Configuration (.env)

```bash
# ===================================
# Admin API Configuration (SSOT - for sync/resolution)
# ===================================
SAAS_ADMIN_API_URL=http://saas-admin-nginx
SAAS_ADMIN_API_TOKEN=<generate-in-admin-app>
SAAS_ADMIN_API_TIMEOUT=5
SAAS_ADMIN_RESOLUTION_CACHE_TTL=3600

# ===================================
# JWT Configuration (Admin App -> Tenant App)
# ===================================
SAAS_ADMIN_JWT_PUBLIC_KEY_PATH=/var/www/html/storage/keys/saas_admin_jwt_public.pem
SAAS_ADMIN_JWT_ISSUER=saas-admin
SAAS_ADMIN_JWT_AUDIENCE=saas-app

# ===================================
# Encryption Configuration
# ===================================
SAAS_APP_ENCRYPTION_PRIVATE_KEY_PATH=/var/www/html/storage/keys/saas_app_encryption_private.pem

# ===================================
# Webhook Configuration (Tenant App -> Admin App)
# ===================================
SAAS_ADMIN_WEBHOOK_URL=http://saas-admin-nginx:80/api/webhooks/tenant-provisioned
SAAS_ADMIN_WEBHOOK_SECRET=<generate-secret>
SAAS_ADMIN_WEBHOOK_TIMEOUT=30
SAAS_ADMIN_WEBHOOK_MAX_RETRIES=3
SAAS_ADMIN_WEBHOOK_RETRY_DELAY=1000
```

### 8.2 Admin App Configuration (.env)

```bash
# ===================================
# Tenant App Configuration (for sending webhooks)
# ===================================
SAAS_APP_WEBHOOK_URL=http://saas-app-nginx/api/internal/tenants/sync
SAAS_APP_WEBHOOK_SECRET=<same-as-tenant-app>
```

---

## 9. Implementation Status

### ✅ Completed Components

| Component | File | Status |
|-----------|-------|--------|
| ReadOnlyModel base class | `app/Models/Base/ReadOnlyModel.php` | ✅ |
| Tenant model read-only enforcement | `app/Models/Admin/Tenant.php` | ✅ |
| Domain model read-only enforcement | `app/Models/Admin/Domain.php` | ✅ |
| TenantResolutionService | `app/Services/TenantResolutionService.php` | ✅ |
| TenantSyncController | `app/Http/Controllers/Internal/TenantSyncController.php` | ✅ |
| TenantsSyncCommand | `app/Console/Commands/TenantsSyncCommand.php` | ✅ |
| Admin TenantProjectionController | `saas-admin-docker/app/app/Http/Controllers/Internal/TenantProjectionController.php` | ✅ |
| VerifyServiceJwt middleware | `app/Http/Middleware/VerifyServiceJwt.php` | ✅ |
| Updated config files | `config/saas-admin.php`, `.env.example` | ✅ |

### ⏳ Remaining Tasks (Optional Enhancements)

| Task | Priority | Notes |
|-------|------------|--------|
| DB-level read-only user | High | Create `taskco_reader` PostgreSQL user |
| Admin App event listeners | High | Emit webhooks on tenant mutations |
| API token generation command | Medium | Generate long-lived tokens for S2S |
| Rate limiting | Medium | Protect internal APIs from abuse |
| Metrics/monitoring | Low | Track resolution latency, cache hit rates |

---

## 10. Readiness Verdict

### ✅ READY FOR PRODUCTION

The architecture is **production-ready** with the following notes:

**Strengths:**
- ✅ Clear SSOT boundary with enforcement
- ✅ Tenant resolution follows strict order (Redis → DB → API → Fail)
- ✅ Event-driven sync for real-time updates
- ✅ Manual sync command as fallback
- ✅ Comprehensive read-only protections at Laravel level
- ✅ Redis cache with proper invalidation
- ✅ Service-to-service JWT authentication
- ✅ Detailed logging and error handling

**Recommendations Before Production:**
1. **Generate read-only DB user** for `central` connection
2. **Set up Admin App event listeners** to emit sync webhooks
3. **Generate long-lived API token** for Tenant App → Admin App communication
4. **Enable HTTPS** on all API endpoints
5. **Set up monitoring** for:
   - Tenant resolution latency
   - Redis cache hit rates
   - API call success/failure rates
   - Sync webhook delivery rates

**Failure Mode Behavior:**
- Admin API down: ✅ Uses cache + DB, fails closed for new tenants
- Redis down: ✅ Falls through to DB, APIs continue working
- Projection DB stale: ✅ Auto-syncs on Admin API calls, manual sync available
- Complete outage: ✅ Returns null/404, never creates fake data

---

## Appendix: Quick Reference

### Key Files

**Tenant App (Data Plane):**
- `/home/shakib/Dev/projects/saas-docker/app/app/Models/Base/ReadOnlyModel.php`
- `/home/shakib/Dev/projects/saas-docker/app/app/Models/Admin/Tenant.php`
- `/home/shakib/Dev/projects/saas-docker/app/app/Models/Admin/Domain.php`
- `/home/shakib/Dev/projects/saas-docker/app/app/Services/TenantResolutionService.php`
- `/home/shakib/Dev/projects/saas-docker/app/app/Http/Controllers/Internal/TenantSyncController.php`
- `/home/shakib/Dev/projects/saas-docker/app/app/Console/Commands/TenantsSyncCommand.php`
- `/home/shakib/Dev/projects/saas-docker/app/app/Http/Middleware/VerifyServiceJwt.php`

**Admin App (Control Plane):**
- `/home/shakib/Dev/projects/saas-admin-docker/app/app/Models/Admin/Tenant.php`
- `/home/shakib/Dev/projects/saas-admin-docker/app/app/Models/Admin/Domain.php`
- `/home/shakib/Dev/projects/saas-admin-docker/app/app/Http/Controllers/Internal/TenantProjectionController.php`

### Key Commands

```bash
# Tenant App: Sync projections from Admin App
php artisan tenants:sync --from=admin

# Tenant App: Clear cache
php artisan cache:clear

# Tenant App: Clear specific cache
php artisan redis:flush --prefix=tenant:resolution

# Admin App: Generate test JWT
php artisan test:generate-token
```

### Redis Keys (for debugging)

```bash
# Connect to Redis
redis-cli

# View all tenant resolution keys
KEYS tenant:resolution:*

# View all projection keys
KEYS tenant:projection:*

# Clear specific tenant cache
DEL tenant:resolution:by-slug:demo

# Clear all resolution cache (dangerous!)
EVAL "return redis.call('del', unpack(redis.call('keys', 'tenant:resolution:*')))" 0
```

---

## Documentation Summary

This guide enforces the **Single Source of Truth (SSOT)** architecture where:

1. **Admin App** owns all tenant data (writes only)
2. **Tenant App** holds read-only projections (for fast resolution)
3. **Redis** provides hot cache with proper invalidation
4. **API** is the only communication channel between apps
5. **Fail closed** - never guess or create fake data

**SSOT Boundary is enforced at:**
- Laravel model level (ReadOnlyModel base class)
- Database level (read-only user - recommended for production)
- Network level (HTTPS + authentication)
- Application level (clear API contracts, no direct DB writes)

**Architecture is production-ready** with clear upgrade paths for monitoring, rate limiting, and enhanced security.
