#!/usr/bin/env bash
# One-shot sweep that removes orphan grants left in mysql.db,
# mysql.tables_priv, and mysql.columns_priv -- rows whose (User, Host) pair
# has no matching row in mysql.user.
#
# Background
#   The older mysqlmgmt::createDatabase() flow unconditionally issued
#   `GRANT ALL PRIVILEGES ON `<db>`.* TO '<owner>'@'%'` without first
#   verifying that '<owner>'@'%' actually existed in mysql.user. The grant
#   landed in mysql.db anyway, producing an "orphan" row. cPanel imports
#   are the most prolific source: the panel/cpanel username (e.g.
#   "alsacorp") is passed as owner, but the real MySQL accounts created
#   for that customer are sub-users like alsacorp_wpuser.
#
#   The forward fix lives in libs/mysqlmgmt.php (pre-flight mysql.user
#   check); this script cleans up the rows already on disk.
#
# Safety
#   - Idempotent: scans first, exits 0 with a "nothing to do" log if
#     no orphans are found.
#   - Allow-listed platform users (root, whpadmin, pma, mariadb.sys,
#     mysql.session, mysql.sys, debian-sys-maint) are NEVER touched.
#     Additionally a defensive `User <> 'root'` clause is applied in
#     every selection query.
#   - For every orphan row identified we (1) log the row to a timestamped
#     paper-trail file BEFORE touching it, then (2) issue a precise
#     `REVOKE ... FROM '<user>'@'<host>'` statement. REVOKE is the right
#     semantic (auto-updates the in-memory grant tables) versus raw DELETE
#     against the mysql.* tables.
#   - REVOKE on a non-existent user errors -- we trap and fall back to a
#     scoped DELETE against the specific (User, Host, Db) row, still
#     logged before execution. This is the safety net for the actual
#     orphan case (no matching user, so REVOKE refuses).
#   - FLUSH PRIVILEGES at the end so the in-memory grant tables align.
#
# Output
#   /var/log/whp/orphan-grant-cleanup-<UTC-timestamp>.log
#     - one line per row identified, with full row data
#     - one line per REVOKE issued (or DELETE fallback)
#     - final summary

set -euo pipefail

LOG_DIR=/var/log/whp
TIMESTAMP=$(date -u +%Y%m%d-%H%M%S)
LOG_FILE="$LOG_DIR/orphan-grant-cleanup-$TIMESTAMP.log"

mkdir -p "$LOG_DIR"
chmod 750 "$LOG_DIR"

log() {
    # tee-style log: stdout + file
    printf '[%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" | tee -a "$LOG_FILE"
}

log "=== orphan-grant cleanup starting (host: $(hostname)) ==="

# --- preflight: mysql client + connectivity ---------------------------------
if ! command -v mysql >/dev/null 2>&1; then
    log "FATAL: mysql client not on PATH"
    exit 1
fi
if [ ! -r /root/.my.cnf ]; then
    log "FATAL: /root/.my.cnf not readable; cannot reach mysql container"
    exit 1
fi
if ! mysql --defaults-file=/root/.my.cnf -Nse 'SELECT 1' >/dev/null 2>&1; then
    log "FATAL: cannot connect to mysql server with /root/.my.cnf"
    exit 1
fi

MYSQL="mysql --defaults-file=/root/.my.cnf -N -B"

# --- platform-user allowlist (NEVER touched) --------------------------------
# Kept as a SQL list so it interpolates cleanly into the WHERE NOT IN (...)
# clauses below. Strings, quoted.
PLATFORM_USERS_SQL="'root','whpadmin','pma','mariadb.sys','mysql.session','mysql.sys','debian-sys-maint','mysql'"

log "platform user allowlist: $PLATFORM_USERS_SQL"

# --- helper: dump candidate rows from a privilege table ---------------------
# Args:
#   $1 = privilege table name (db | tables_priv | columns_priv)
# Output (TSV on stdout, also logged):
#   User<TAB>Host<TAB>Db
# Selection rules:
#   - no matching row in mysql.user for (User, Host)
#   - User not in platform allowlist
#   - User <> 'root'  (defense in depth, even though root is in allowlist)
list_orphans() {
    local table="$1"
    $MYSQL <<SQL
SELECT p.User, p.Host, p.Db
FROM mysql.${table} p
LEFT JOIN mysql.user u
  ON u.User = p.User AND u.Host = p.Host
WHERE u.User IS NULL
  AND p.User NOT IN (${PLATFORM_USERS_SQL})
  AND p.User <> 'root'
  AND p.User <> 'whpadmin'
  AND p.User <> 'pma'
ORDER BY p.User, p.Host, p.Db;
SQL
}

# --- helper: revoke (or fallback delete) a single orphan row ----------------
# Args: table, user, host, db
revoke_one() {
    local table="$1" user="$2" host="$3" db="$4"

    # Belt-and-braces: never proceed if (User, Host, Db) lacks the basics or
    # if user is somehow in the allowlist.
    case "$user" in
        root|whpadmin|pma|mariadb.sys|mysql.session|mysql.sys|debian-sys-maint|mysql|"")
            log "SKIP (allowlist guard): table=$table user='$user' host='$host' db='$db'"
            return 0
            ;;
    esac

    # SQL-escape single quotes and backticks for identifier/value safety.
    local user_esc host_esc db_esc
    user_esc=${user//\'/\'\'}
    host_esc=${host//\'/\'\'}
    db_esc=${db//\`/\`\`}

    # Try REVOKE first -- correct semantic, FLUSH-aware.
    # On a true orphan (user truly missing from mysql.user) REVOKE errors;
    # the trap below falls back to a scoped row delete from the specific
    # privilege table.
    local revoke_sql
    case "$table" in
        db)
            revoke_sql="REVOKE ALL PRIVILEGES ON \`${db_esc}\`.* FROM '${user_esc}'@'${host_esc}';"
            ;;
        tables_priv|columns_priv)
            revoke_sql="REVOKE ALL PRIVILEGES ON \`${db_esc}\`.* FROM '${user_esc}'@'${host_esc}';"
            ;;
        *)
            log "FATAL: unknown privilege table '$table' -- aborting"
            exit 1
            ;;
    esac

    log "REVOKE(attempt) table=$table user='$user' host='$host' db='$db' sql=${revoke_sql}"

    if $MYSQL -e "$revoke_sql" 2>>"$LOG_FILE"; then
        log "REVOKE(ok) table=$table user='$user' host='$host' db='$db'"
        return 0
    fi

    log "REVOKE(fail) table=$table user='$user' host='$host' db='$db' -- falling back to scoped row removal"

    # Fallback: remove exactly the orphan row. Still scoped by (User, Host, Db)
    # plus the allowlist guard inside the WHERE clause. This is what handles
    # the actual orphan case (REVOKE refuses against a non-existent user).
    local fallback_sql
    fallback_sql="DELETE FROM mysql.${table} WHERE User='${user_esc}' AND Host='${host_esc}' AND Db='${db_esc}' AND User NOT IN (${PLATFORM_USERS_SQL}) AND User <> 'root' LIMIT 1;"

    log "FALLBACK(attempt) table=$table user='$user' host='$host' db='$db' sql=${fallback_sql}"
    if $MYSQL -e "$fallback_sql" 2>>"$LOG_FILE"; then
        log "FALLBACK(ok) table=$table user='$user' host='$host' db='$db'"
        return 0
    fi

    log "FALLBACK(fail) table=$table user='$user' host='$host' db='$db' -- left in place for manual review"
    return 1
}

# --- main: scan all three privilege tables ----------------------------------
TOTAL=0
REVOKED=0
FAILED=0

for TABLE in db tables_priv columns_priv; do
    log "--- scanning mysql.${TABLE} for orphan grants ---"

    # Capture into a tmpfile so we can both log and iterate.
    tmpfile=$(mktemp -t whp-orphan-grants-${TABLE}.XXXXXX)
    if ! list_orphans "$TABLE" > "$tmpfile" 2>>"$LOG_FILE"; then
        log "WARN: orphan scan failed for mysql.${TABLE} (table may not exist on this server)"
        rm -f -- "$tmpfile"
        continue
    fi

    count=$(wc -l < "$tmpfile" | tr -d ' ')
    log "found ${count} orphan row(s) in mysql.${TABLE}"

    if [ "$count" -eq 0 ]; then
        rm -f -- "$tmpfile"
        continue
    fi

    # Log each row BEFORE touching anything (paper trail for rollback).
    log "--- paper trail (mysql.${TABLE}) ---"
    while IFS=$'\t' read -r U H D; do
        log "ORPHAN table=${TABLE} user='${U}' host='${H}' db='${D}'"
    done < "$tmpfile"

    # Now iterate and revoke.
    log "--- revoking (mysql.${TABLE}) ---"
    while IFS=$'\t' read -r U H D; do
        TOTAL=$((TOTAL + 1))
        if revoke_one "$TABLE" "$U" "$H" "$D"; then
            REVOKED=$((REVOKED + 1))
        else
            FAILED=$((FAILED + 1))
        fi
    done < "$tmpfile"

    rm -f -- "$tmpfile"
done

# --- finalize ---------------------------------------------------------------
if [ "$TOTAL" -eq 0 ]; then
    log "nothing to do -- no orphan grants found across mysql.db / tables_priv / columns_priv"
    log "=== orphan-grant cleanup complete (0 rows) ==="
    exit 0
fi

log "issuing FLUSH PRIVILEGES"
$MYSQL -e 'FLUSH PRIVILEGES;' 2>>"$LOG_FILE"

log "=== orphan-grant cleanup complete: total=${TOTAL} revoked=${REVOKED} failed=${FAILED} ==="
log "paper trail: $LOG_FILE"

if [ "$FAILED" -gt 0 ]; then
    # Non-fatal: log says what's left, operator can hand-review. We still
    # exit 0 so update.sh doesn't abort the rest of the upgrade for rows
    # that will be flagged for manual cleanup.
    log "WARN: ${FAILED} row(s) could not be removed automatically -- review the log and clean by hand"
fi

exit 0
