# Security Deployment Runbook

**Branch:** `whatsapp-int` (current working branch)
**Scope:** All Phase 1–4 fixes + post-Phase 4 follow-ups + URL routing for MyFatoorah webhook

## Environment URLs

| Env | API | Frontend | Backend | Storage |
|-----|-----|----------|---------|---------|
| **QC** | `https://stageapi.navagoo.com` | `https://stageshops.navagoo.com` | `https://stageadmin.navagoo.com` | `https://stagestorage.navagoo.com` |
| **Prod** | `https://apinavagoo.navagoo.com` | `https://shops.navagoo.com` | `https://adminnavagoo.navagoo.com` | `https://storagenavago.navagoo.com` |

---

## Pre-Deploy Baseline (run BEFORE deploying — captures current state)

Run these against QC. Save the output to compare after deploy.

```bash
QC_API="https://stageapi.navagoo.com"

# A1. Auth required on /booking (should already 401)
curl -sk -w "HTTP=%{http_code}\n" "$QC_API/booking/1"

# A2. Auth required on /agent/bookings (should already 401)
curl -sk -w "HTTP=%{http_code}\n" "$QC_API/agent/bookings/1"

# A3. Paymob webhook rejects missing HMAC (already in old code)
curl -sk -w "HTTP=%{http_code}\n" -X POST -H "Content-Type: application/json" \
  -d '{"type":"TRANSACTION","obj":{"id":0}}' \
  "$QC_API/webhook/paymob"
# Expected BEFORE: HTTP=200, body "Invalid Webhook Data or Missing HMAC"

# A4. MyFatoorah webhook (CURRENTLY UNROUTED)
curl -sk -w "HTTP=%{http_code}\n" -X POST -H "Content-Type: application/json" \
  -d '{}' \
  "$QC_API/webhook/myfatoorah"
# Expected BEFORE: HTTP=500, body "صفحة غير موجودة" (Arabic: page not found)
# Expected AFTER:  HTTP=401, body "Signature verification failed"
```

QC baseline captured 2026-04-30 (verified): A1=401, A2=401, A3=200/"Missing HMAC", A4=500/page-not-found.

---

## Deploy Order

1. **Push branch + merge** to whichever release branch QC pulls from.
2. **Pull on QC server**, then on QC:
   ```bash
   cd /path/to/Navagoo
   composer install --no-dev --optimize-autoloader
   php yii cache/flush-all
   ```
3. **Run pending migrations** on QC database:
   ```bash
   php console/yii migrate/up --interactive=0
   ```
   Two new migrations will apply:
   - `m260430_000000_payment_idempotency`
   - `m260501_000000_payment_paid_amount_decimal`

   **If `php yii migrate` says "No new migrations found" but the schema is missing
   `user.token_expires_at` and `sms_log.verification_attempts`**, these earlier
   migrations were lexicographically skipped. Apply manually:
   ```sql
   ALTER TABLE user
     ADD COLUMN token_expires_at DATETIME NULL
       COMMENT 'Expiration timestamp for access_token. Null = legacy/never expires';
   CREATE INDEX idx_user_token_expiration ON user (token_expires_at);
   INSERT INTO system_db_migration (version, apply_time)
     VALUES ('m260429_add_token_expiration', UNIX_TIMESTAMP());

   ALTER TABLE sms_log
     ADD COLUMN verification_attempts INT DEFAULT 0
       COMMENT 'Count of failed OTP verification attempts',
     ADD COLUMN last_verification_attempt DATETIME NULL
       COMMENT 'Timestamp of last OTP verification attempt';
   CREATE INDEX idx_sms_log_verification_check ON sms_log (user_id, type, created_at);
   INSERT INTO system_db_migration (version, apply_time)
     VALUES ('m260429_add_otp_verification_rate_limiting', UNIX_TIMESTAMP());
   ```

4. **If the idempotency migration aborts** with "tran_ref has duplicates":
   ```sql
   -- Identify them
   SELECT tran_ref, COUNT(*) FROM payment
     WHERE tran_ref IS NOT NULL AND tran_ref != ''
     GROUP BY tran_ref HAVING COUNT(*) > 1;
   -- Reconcile against Paymob/MyFatoorah dashboard, then NULL out the duplicates
   -- (keep the canonical row by lowest id) and re-run the migration.
   ```

5. **Set required env vars** in QC `.env`:
   ```
   TOKEN_EXPIRATION_DAYS=30
   SMS_PROVIDER_ENABLED=false              # for QC (true for prod)
   MY_FATOORAH_WEBHOOK_SECRET=<from-portal>
   WEBHOOK_IP_ALLOWLIST=                   # leave empty initially
   OTP_TEST_NUMBERS=                       # leave empty unless reviewing app

   # CORS allow-list — browser origins permitted to call this API.
   # Mobile apps are unaffected (no Origin header sent).
   ALLOWED_ORIGINS=https://stageshops.navagoo.com,https://stageadmin.navagoo.com
   ```

   For **prod** `.env`, the same vars except:
   ```
   SMS_PROVIDER_ENABLED=true
   ALLOWED_ORIGINS=https://shops.navagoo.com,https://adminnavagoo.navagoo.com
   ```

6. **Soak on QC for at least 24 hours** before promoting to prod.

7. **Repeat steps 2–5** on prod, scheduled for low-traffic window. Have the
   `safeDown()` of both new migrations ready as rollback.

---

## Post-Deploy Verification (run on QC after deploy)

```bash
QC_API="https://stageapi.navagoo.com"

# V1. IDOR — needs 2 customer accounts. Replace TOKEN_A and BOOKING_OF_B.
TOKEN_A="<customer-A's-bearer-token-from-DB>"
BOOKING_OF_B="<id-of-a-booking-owned-by-different-customer>"
curl -sk -w "HTTP=%{http_code}\n" \
  -H "Authorization: Bearer $TOKEN_A" \
  "$QC_API/booking/$BOOKING_OF_B"
# Expected: HTTP=404  (BEFORE the fix this returned 200 with B's data)

# V2. MyFatoorah webhook now reachable + signature verified
curl -sk -w "HTTP=%{http_code}\n" -X POST \
  -H "Content-Type: application/json" \
  -d '{"Event":"PaymentStatusChanged","Data":{"InvoiceId":"x"}}' \
  "$QC_API/webhook/myfatoorah"
# Expected: HTTP=401, body "Signature verification failed"
# (proves: route registered + HMAC enforced + fail-closed without secret)

# V3. Paymob webhook still rejects bogus HMAC
curl -sk -w "HTTP=%{http_code}\n" -X POST \
  -H "Content-Type: application/json" \
  -d '{"type":"TRANSACTION","obj":{"id":1,"amount_cents":100}}' \
  "$QC_API/webhook/paymob?hmac=bogus"
# Expected: HTTP=200, body "HMAC Verification Failed"

# V4. Token expiry issued on new logins (run after a real login)
mysql -e "
SELECT id, token_expires_at,
       CASE WHEN token_expires_at IS NOT NULL THEN 'OK' ELSE 'LEGACY' END AS state
FROM user
WHERE id = <some-user-who-logged-in-after-deploy>;
"
# Expected: token_expires_at populated, state='OK'

# V5. Logout invalidates token
curl -sk -w "HTTP=%{http_code}\n" \
  -H "Authorization: Bearer $TOKEN_A" \
  "$QC_API/profile/logout"
# Then immediately re-use the SAME token:
curl -sk -w "HTTP=%{http_code}\n" \
  -H "Authorization: Bearer $TOKEN_A" \
  "$QC_API/booking/<own-id>"
# Expected: second call HTTP=401 (token invalidated)

# V6. OTP IP rate-limit
for i in 1 2 3 4 5 6 7 8 9 10 11 12; do
  curl -sk -w "$i: HTTP=%{http_code}\n" -X POST \
    -d "mobile=05551234${i}" "$QC_API/user/customer-sign-in"
done
# Expected: first ~10 succeed (or hit per-user limit), 11th+ →
# "Too many OTP requests from your network..."

# V7. DB-level UNIQUE on tran_ref
mysql -e "
INSERT INTO payment (booking_id, tran_ref, status) VALUES (NULL,'V7-TEST',1);
INSERT INTO payment (booking_id, tran_ref, status) VALUES (NULL,'V7-TEST',1);
DELETE FROM payment WHERE tran_ref='V7-TEST';
"
# Expected: second INSERT errors with 'Duplicate entry V7-TEST for key uniq_payment_tran_ref'

# V8. paid_amount is DECIMAL
mysql -e "SHOW CREATE TABLE payment\G" | grep paid_amount
# Expected: 'paid_amount' decimal(12,2)

# V9. CORS allow-list — request from allowed origin echoes the origin back
curl -sk -i -X POST \
  -H "Origin: https://stageshops.navagoo.com" \
  -H "Content-Type: application/json" -d '{}' \
  "$QC_API/shops/list-all" | grep -i "access-control-allow-origin"
# Expected: Access-Control-Allow-Origin: https://stageshops.navagoo.com
# (specific origin echoed, NOT a wildcard `*`)

# V10. CORS rejects unlisted origin (server returns no CORS header)
curl -sk -i -X POST \
  -H "Origin: https://evil.example.com" \
  -H "Content-Type: application/json" -d '{}' \
  "$QC_API/shops/list-all" | grep -i "access-control-allow-origin"
# Expected: empty — NO Access-Control-Allow-Origin header echoed.
# Browser then refuses to expose the response body to the calling JS.
```

---

## Production-Safe Subset (no destructive ops)

For prod verification, run **only** the read-only checks: V2, V3, V8.
**Do NOT** run V6 (would create real OTP rate-limit blocks for that IP),
V7 (modifies the payment table), or V4/V5 unless you have a dedicated test
account.

```bash
PROD_API="https://apinavagoo.navagoo.com"

# Prod V2: MyFatoorah webhook reachable + signature enforced
curl -sk -w "HTTP=%{http_code}\n" -X POST \
  -H "Content-Type: application/json" -d '{}' \
  "$PROD_API/webhook/myfatoorah"
# Expected: HTTP=401, body "Signature verification failed"

# Prod V3: Paymob webhook still rejects bogus HMAC
curl -sk -w "HTTP=%{http_code}\n" -X POST \
  -H "Content-Type: application/json" \
  -d '{"type":"TRANSACTION","obj":{"id":1}}' \
  "$PROD_API/webhook/paymob?hmac=bogus"
# Expected: HTTP=200, body "HMAC Verification Failed"

# Prod V8: schema check
mysql -e "SHOW CREATE TABLE payment\G" | grep -E 'paid_amount|uniq_'
# Expected: paid_amount decimal(12,2), UNIQUE KEY uniq_payment_tran_ref, uniq_payment_idempotency_key
```

---

## Rollback

If something breaks **before** any new payments have been written with the
new schema, both migrations are reversible:

```bash
php console/yii migrate/down 2 --interactive=0
# Reverts:
#   m260501_000000_payment_paid_amount_decimal  (DECIMAL → DOUBLE — lossy: 2dp values stay 2dp)
#   m260430_000000_payment_idempotency           (drops idempotency_key + UNIQUE indexes)
```

Code-side rollback is just a `git revert` of the security-fix commits — every
fix is non-breaking on its own (legacy tokens still work, missing env vars
disable filters fail-open in non-critical paths and fail-closed in critical
ones).

---

## Provider-Side Credential Rotation (operational, not in code)

Required because the OLD secrets were committed in `.env.dist` until this
session and via `CODE_ANALYSIS_REPORT.md`. Rotate at:

| Provider | What to rotate | Where |
|----------|----------------|-------|
| Paymob | API key + HMAC secret | Paymob merchant dashboard → Integrations |
| MyFatoorah | API key + (configure) Webhook secret | MyFatoorah portal → Vendor Settings |
| Google Maps | API key | Google Cloud Console → APIs & Services |
| SMTP (Mailtrap) | username + password | Mailtrap account → Inbox settings |
| Glide | Signing key | Generate new 32-char random; update GLIDE_SIGN_KEY |
| MySQL | `root`/`root` if still in use | rotate to per-env credentials |
| Frontend cookie key | regenerate 32+ chars | per env |
| Backend cookie key | regenerate 32+ chars | per env |

After rotating, the new values go into the per-env `.env` (gitignored).
`.env.dist` keeps `CHANGE_ME` placeholders only.
