#!/usr/bin/env bash
# nems-userctl
# Unified user management for NEMS Linux - https://nemslinux.com/
# By Robbie Ferguson // Bald Nerd
# Version 1.0 // October 29, 2025 - Initial release
# Version 1.1 // October 30, 2025 - Add audit log
# Version 1.2 // October 31, 2025 - 

set -euo pipefail

# ---- Config ----
HTPASSWD_FILE="/var/www/htpasswd"
DB_DIR="/usr/local/share/nems/database"
DB_FILE="${DB_DIR}/users.db"
NEMS_CONF="/usr/local/share/nems/nems.conf"

MANAGED_GROUPS=(sudo www-data nagios gpio netdev monit)

# ---- Role map (extend here) ----
declare -A ROLE_GROUPS
ROLE_GROUPS[superadmin]="sudo www-data nagios gpio netdev monit"
ROLE_GROUPS[admin]="sudo www-data nagios gpio netdev monit"
ROLE_GROUPS[operator]="www-data nagios gpio netdev monit"
ROLE_GROUPS[reporter]="www-data nagios"
ROLE_GROUPS[viewer]="www-data"

# Precedence for privilege checks (higher number = more privilege)
declare -A ROLE_PREC=( [viewer]=1 [reporter]=2 [operator]=3 [admin]=4 [superadmin]=5 )

# Setup audit log
LOGFILE="/var/log/nems/nems-userctl.log"
log_action() {
    # $1 = action (string)
    # $2 = target username
    # $3 = actor
    # $4 = detail (optional)
    local _ts
    _ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
    # Use >> append, root-owned script so only root can write this
    printf '%s action=%s target=%s actor=%s detail=%s\n' \
        "$_ts" "$1" "$2" "$3" "$4" >> "$LOGFILE"
}

# ---- Utils ----
log(){ logger -t nems-userctl "$*"; }
die(){ echo "ERROR: $*" >&2; log "ERROR: $*"; exit 1; }
need_root(){ [[ ${EUID} -eq 0 ]] || die "Must run as root."; }
now_epoch(){ date +%s; }

check_bins(){
  command -v useradd >/dev/null 2>&1 || die "useradd missing."
  command -v usermod >/dev/null 2>&1 || die "usermod missing."
  command -v userdel >/dev/null 2>&1 || die "userdel missing."
  command -v chpasswd >/dev/null 2>&1 || die "chpasswd missing."
  command -v smbpasswd >/dev/null 2>&1 || die "smbpasswd (samba) missing."
  command -v htpasswd >/dev/null 2>&1 || die "htpasswd (apache2-utils) missing."
  command -v sqlite3  >/dev/null 2>&1 || die "sqlite3 missing."
}

usage(){
  cat <<EOF
NEMS unified user management
By Robbie Ferguson // Bald Nerd

USAGE:
  nems-userctl create   --user <name> [--role <role>] [--password-stdin | --password-file <path>] [--superadmin-create]
  nems-userctl delete   --user <name> [--actor <name>]
  nems-userctl set-role --user <name> --role <role> [--actor <name>]
  nems-userctl set-pass --user <name> [--password-stdin | --password-file <path>] [--actor <name>]
  nems-userctl info     --user <name> [--json]
  nems-userctl list-roles
  nems-userctl list-users

ROLES:
  $(printf '%s ' "${!ROLE_GROUPS[@]}")

Password input:
  --password-stdin             Read password from STDIN (recommended)
  --password-file <path>       Read password from a file (single line)

EOF
}

get_boot_superadmin() {
  nemsuser=$(/usr/local/bin/nems-info username)
  echo $nemsuser | tr -d '\r\n'
}

have_user(){ id -u "$1" >/dev/null 2>&1; }

ensure_htpasswd(){
  if [[ ! -e "$HTPASSWD_FILE" ]]; then
    install -m 640 -o root -g www-data /dev/null "$HTPASSWD_FILE"
  fi
  chgrp www-data "$HTPASSWD_FILE" || true
  chmod 640 "$HTPASSWD_FILE" || true
}

read_password() {
  local PW=""
  if [[ ${PASSWORD_STDIN:-0} -eq 1 ]]; then
    # Old behavior: read from normal stdin
    if IFS= read -r PW; then
      : # got it
    else
      die "No password on stdin."
    fi
  elif [[ -n ${PASSWORD_FILE:-} ]]; then
    PW=$(head -n1 "$PASSWORD_FILE" 2>/dev/null || true)
    [[ -n "$PW" ]] || die "Password file empty."
  else
    die "Password not provided. Use --password-stdin or --password-file."
  fi

  printf '%s' "$PW"
}

linux_create_user(){
  local u="$1" pw="$2"
  if ! have_user "$u"; then
    useradd -m -s /bin/bash "$u"
    echo "${u}:${pw}" | chpasswd
    log "Created Linux user $u"
  else
    echo "${u}:${pw}" | chpasswd
    log "Updated Linux password for $u"
  fi
}

linux_delete_user(){
  local u="$1"
  if have_user "$u"; then
    userdel -r "$u" || die "Failed to delete Linux user $u"
    log "Deleted Linux user $u (and home)"
  fi
}

samba_upsert(){
  local u="$1" pw="$2"
  (echo "$pw"; echo "$pw") | smbpasswd -s -a "$u"
  smbpasswd -e "$u" >/dev/null 2>&1 || true
  log "Samba user ensured for $u"
}

samba_delete(){
  local u="$1"
  smbpasswd -x "$u" >/dev/null 2>&1 || true
  log "Removed Samba user $u"
}

htpasswd_upsert(){
  local u="$1" pw="$2"
  ensure_htpasswd
  echo "$pw" | htpasswd -B -i "$HTPASSWD_FILE" "$u" >/dev/null
  log "htpasswd ensured for $u"
}

htpasswd_delete(){
  local u="$1"
  if [[ -f "$HTPASSWD_FILE" ]]; then
    htpasswd -D "$HTPASSWD_FILE" "$u" >/dev/null 2>&1 || true
    log "Removed htpasswd entry for $u"
  fi
}

set_role_groups(){
  local u="$1" role="$2"
  [[ -n ${ROLE_GROUPS[$role]:-} ]] || die "Unknown role: $role"
  local desired=(${ROLE_GROUPS[$role]})

  for g in "${desired[@]}"; do
    getent group "$g" >/dev/null 2>&1 || groupadd "$g"
    gpasswd -a "$u" "$g" >/dev/null
  done

  for g in "${MANAGED_GROUPS[@]}"; do
    if printf '%s\0' "${desired[@]}" | grep -zqx "$g"; then :; else
      gpasswd -d "$u" "$g" >/dev/null 2>&1 || true
    fi
  done
  log "Applied role '$role' to $u"
}

# ---- DB init & helpers ----
ensure_db(){
  install -d -m 0755 -o root -g root "$DB_DIR"
  if [[ ! -f "$DB_FILE" ]]; then
    install -m 0600 -o root -g root /dev/null "$DB_FILE"
    sqlite3 "$DB_FILE" <<'SQL'
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS users(
  username   TEXT PRIMARY KEY,
  role       TEXT NOT NULL,
  created_at INTEGER NOT NULL,
  updated_at INTEGER NOT NULL,
  deleted_at INTEGER
);
CREATE TABLE IF NOT EXISTS user_audit(
  id        INTEGER PRIMARY KEY,
  username  TEXT NOT NULL,
  action    TEXT NOT NULL,
  old_role  TEXT,
  new_role  TEXT,
  actor     TEXT,
  ts        INTEGER NOT NULL
);
SQL
  else
    chmod 0600 "$DB_FILE" || true
    chown root:root "$DB_FILE" || true
  fi
}

# This will later be used for nems-init.
set_superadmin_username() {
  local u="$1"

  [[ -n "$u" ]] || die "No username provided for NEMS Admin user."

  # ensure nems.conf exists
  if [[ ! -f "$NEMS_CONF" ]]; then
    install -m 0600 -o root -g root /dev/null "$NEMS_CONF"
  fi

  # if nems.conf already has username=..., refuse to overwrite
  if grep -qE '^nemsadmin=' "$NEMS_CONF"; then
    die "NEMS Admin is already initialized."
  fi

  # append nemsadmin=... to nems.conf
  printf 'nemsadmin=%s\n' "$u" >> "$NEMS_CONF"

  # lock perms
  chown root:root "$NEMS_CONF"
  chmod 0600 "$NEMS_CONF"
}

sqlq() {  # quote a non-empty string for SQL
  local s="$1"
  s=${s//\'/\'\'}           # escape single quotes
  printf "'%s'" "$s"
}

sqln() {  # NULL if empty, else quoted
  local s="${1-}"
  if [[ -z "$s" ]]; then printf "NULL"; else sqlq "$s"; fi
}

db_upsert_user(){
  local u="$1" role="$2" t; t=$(now_epoch)
  sqlite3 "$DB_FILE" \
    "INSERT INTO users(username,role,created_at,updated_at)
     VALUES($(sqlq "$u"),$(sqlq "$role"),$t,$t)
     ON CONFLICT(username) DO UPDATE SET role=excluded.role, updated_at=$t, deleted_at=NULL;"
}

db_touch_pass(){
  local u="$1" t; t=$(now_epoch)
  sqlite3 "$DB_FILE" \
    "UPDATE users SET updated_at=$t WHERE username=$(sqlq "$u");" || true
}

db_mark_deleted(){
  local u="$1" t; t=$(now_epoch)
  sqlite3 "$DB_FILE" \
    "UPDATE users SET deleted_at=$t, updated_at=$t WHERE username=$(sqlq "$u");" || true
}

db_audit(){
  local u="$1" action="$2" old="${3-}" new="${4-}" actor="$5" t; t=$(now_epoch)
  sqlite3 "$DB_FILE" \
    "INSERT INTO user_audit(username,action,old_role,new_role,actor,ts)
     VALUES($(sqlq "$u"),$(sqlq "$action"),$(sqln "$old"),$(sqln "$new"),$(sqlq "$actor"),$t);" || true
}

db_get_role(){
  local u="$1"
  sqlite3 -noheader -batch "$DB_FILE" \
    "SELECT role FROM users WHERE username=$(sqlq "$u") AND deleted_at IS NULL LIMIT 1;"
}

# ---- Privilege guards ----
forbid_self_action(){
  local target="$1"
  if [[ -n "${SUDO_USER:-}" && "$SUDO_USER" == "$target" ]]; then die "Users cannot perform this action on themselves."; fi
}
prec_of(){ echo "${ROLE_PREC[$1]:-0}"; }
forbid_higher_or_equal() {
  local actor="$1"
  local target="$2"
  local action="$3"   # optional, e.g. "delete", "set-role", "set-pass", "create"

  local actor_role target_role
  local ar tr
  local boot_super

  actor_role="$(db_get_role "$actor")"
  target_role="$(db_get_role "$target")"

  # Fallbacks:
  [[ -z "$actor_role" ]] && actor_role="admin"     # CLI/root usage gets treated like admin unless overridden
  [[ -z "$target_role" ]] && target_role="viewer"  # new/unknown users look like 'viewer'

  ar=$(prec_of "$actor_role")
  tr=$(prec_of "$target_role")
  boot_super="$(get_boot_superadmin)"

  # --- 1. Block acting on yourself (except password, which we'll handle at callsite)
  if [[ "$actor" == "$target" ]]; then
    # We don't allow self-delete or self-role changes.
    # set-pass is allowed, but set-pass shouldn't call forbid_higher_or_equal() for self anyway.
    die "Insufficient privilege: cannot act on yourself."
  fi

  # --- 2. Protect the boot/primary NEMS Admin account (immutable owner account)
  # Nobody can delete or change role on $boot_super.
  if [[ -n "$boot_super" && "$target" == "$boot_super" ]]; then
    # If we're doing delete or set-role, that's forbidden.
    if [[ "$action" == "delete" || "$action" == "set-role" ]]; then
      die "Insufficient privilege: cannot $action the primary NEMS Admin account."
    fi
    # For set-pass on the boot account, caller should enforce extra rules.
    # We allow forbid_higher_or_equal() to fall through here because
    # password changes might still be okay with extra checks elsewhere.
  fi

  # --- 3. Non-superadmins cannot touch equal-or-higher roles
  if [[ "$actor_role" != "superadmin" ]]; then
    # If target role precedence is >= actor role precedence:
    if (( tr >= ar )); then
      die "Insufficient privilege: $actor_role cannot act on $target ($target_role)."
    fi
  else
    # --- 4. Actor is superadmin
    # superadmin can act on other superadmins, except:
    # - they can't act on themselves (handled above),
    # - they can't delete/demote the boot superadmin (handled above).
    # So: no additional block here.
    :
  fi

  # if we reach here, it's allowed
  return 0
}

# ---- Parse args ----
need_root
check_bins
ensure_db

ACTION="${1:-}"; [[ -z "$ACTION" ]] && { usage; exit 1; }; shift || true

USER=""
ROLE=""
PASSWORD_STDIN=0
PASSWORD_FILE=""
ACTOR="<system>"
JSON=0
SUPERADMIN_CREATE=0

while [[ $# -gt 0 ]]; do
  case "$1" in
    --user) USER="$2"; shift 2;;
    --role) ROLE="$2"; shift 2;;
    --password-stdin) PASSWORD_STDIN=1; shift;;
    --password-file) PASSWORD_FILE="$2"; shift 2;;
    --actor) ACTOR="$2"; shift 2;;
    --json) JSON=1; shift;;
    --superadmin-create) SUPERADMIN_CREATE=1; shift;;
    -h|--help) usage; exit 0;;
    *) die "Unknown arg: $1";;
  esac
done

# Auto-detect actor if not provided
if [[ "$ACTOR" == "<system>" ]]; then
  if [[ -n "${SUDO_USER:-}" ]]; then
    ACTOR="$SUDO_USER"
  elif [[ -n "${USER:-}" ]]; then
    ACTOR="$USER"
  else
    ACTOR="root"
  fi
fi


# ---- Actions ----
case "$ACTION" in
  create)
    [[ -n "$USER" ]] || die "--user required"
    forbid_higher_or_equal "$ACTOR" "$USER" "create"

    actor_role="$(db_get_role "$ACTOR")"
    [[ -z "$actor_role" ]] && actor_role="admin"  # fallback

    # Block promotions above actor:
    actor_prec=$(prec_of "$actor_role")
    new_prec=$(prec_of "$ROLE")
    if (( new_prec > actor_prec )); then
      die "Insufficient privilege: $actor_role cannot assign role '$ROLE'."
    fi

    PW="$(read_password)"
    if (( SUPERADMIN_CREATE )); then
      # create superadmin; ensure not already set
      [[ -z "$(get_boot_superadmin)" ]] || die "Superadmin already exists."
      ROLE="superadmin"
    fi
    linux_create_user "$USER" "$PW"
    samba_upsert "$USER" "$PW"
    htpasswd_upsert "$USER" "$PW"

    # Set the role as provided, or as viewer if none provided
    effective_role="${ROLE:-viewer}"
    set_role_groups "$USER" "$effective_role"
    db_upsert_user "$USER" "$effective_role"
    db_audit "$USER" "create" "" "$effective_role" "$ACTOR"
    log_action "create" "$USER" "$ACTOR" "role=$effective_role"

    if (( SUPERADMIN_CREATE )); then set_superadmin_username "$USER"; fi
    echo "OK: created/updated user '$USER'."
  ;;

  delete)
    [[ -n "$USER" ]] || die "--user required"
    # protections
    forbid_self_action "$USER"

    # Entirely block this action on the NEMS Admin user (from nems-init)
    boot_super="$(get_boot_superadmin)"
    if [[ -n "$boot_super" && "$USER" == "$boot_super" ]]; then
      die "Insufficient privilege: cannot delete primary NEMS Admin."
    fi

    [[ "$ACTOR" != "<system>" ]] && forbid_higher_or_equal "$ACTOR" "$USER" "delete"
    old_role="$(db_get_role "$USER")"
    htpasswd_delete "$USER"
    samba_delete "$USER"
    linux_delete_user "$USER"
    db_mark_deleted "$USER"
    db_audit "$USER" "delete" "$old_role" "" "$ACTOR"
    echo "OK: deleted user '$USER'."
    log_action "delete" "$USER" "$ACTOR" ""
  ;;

  set-role)
    [[ -n "$USER" && -n "$ROLE" ]] || die "--user and --role required"
    have_user "$USER" || die "User $USER not found."

    actor_role="$(db_get_role "$ACTOR")"
    [[ -z "$actor_role" ]] && actor_role="admin"  # fallback

    # Block promotions above actor:
    actor_prec=$(prec_of "$actor_role")
    new_prec=$(prec_of "$ROLE")
    if (( new_prec > actor_prec )); then
      die "Insufficient privilege: $actor_role cannot assign role '$ROLE'."
    fi

    # Entirely block this action on the NEMS Admin user (from nems-init)
    boot_super="$(get_boot_superadmin)"
    if [[ -n "$boot_super" && "$USER" == "$boot_super" ]]; then
      die "Insufficient privilege: cannot change role of primary NEMS Admin."
    fi

    [[ "$ACTOR" != "<system>" ]] && forbid_higher_or_equal "$ACTOR" "$USER" "set-role"
    old_role="$(db_get_role "$USER")"
    set_role_groups "$USER" "$ROLE"
    db_upsert_user "$USER" "$ROLE"
    db_audit "$USER" "set-role" "$old_role" "$ROLE" "$ACTOR"
    echo "OK: role '$ROLE' applied to '$USER'."
    log_action "set-role" "$USER" "$ACTOR" "newrole=$ROLE"
  ;;

  set-pass)
    [[ -n "$USER" ]] || die "--user required"
    # Password changes:
    # - Allowed for self (including superadmin)
    # - For others: must have higher privilege; cannot target superadmin
    self_change=0
    if [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" == "$USER" ]]; then self_change=1; fi
    if [[ "$ACTOR" == "$USER" ]]; then self_change=1; fi

    if (( self_change )); then
      : # allowed (even if superadmin)
    else
      # not self, so enforce normal hierarchy
      forbid_higher_or_equal "$ACTOR" "$USER" "set-pass"

      # Extra boot superadmin restriction:
      boot_super="$(get_boot_superadmin)"
      if [[ -n "$boot_super" && "$USER" == "$boot_super" ]]; then
          # Only allow if actor is also superadmin
          actor_role="$(db_get_role "$ACTOR")"
          if [[ "$actor_role" != "superadmin" ]]; then
              die "Insufficient privilege: cannot change password for primary NEMS Admin."
          fi
      fi
    fi

    PW="$(read_password)"
    echo "${USER}:${PW}" | chpasswd
    samba_upsert "$USER" "$PW"
    htpasswd_upsert "$USER" "$PW"
    db_touch_pass "$USER"
    db_audit "$USER" "set-pass" "" "" "$ACTOR"
    echo "OK: password updated for '$USER'."
    log_action "set-pass" "$USER" "$ACTOR" ""
  ;;

  info)
    [[ -n "$USER" ]] || die "--user required"
    if (( JSON )); then
      role="$(db_get_role "$USER")"
      created=$(sqlite3 "$DB_FILE" "SELECT created_at FROM users WHERE username=$(sqlq "$USER");")
      updated=$(sqlite3 "$DB_FILE" "SELECT updated_at FROM users WHERE username=$(sqlq "$USER");")
      deleted=$(sqlite3 "$DB_FILE" "SELECT deleted_at FROM users WHERE username=$(sqlq "$USER");")
      groups="$(id -nG "$USER" 2>/dev/null || true)"
      printf '{"username":"%s","role":"%s","created_at":%s,"updated_at":%s,"deleted_at":%s,"groups":[%s]}\n' \
        "$USER" "${role:-""}" "${created:-null}" "${updated:-null}" "${deleted:-null}" \
        "$(printf '%s' "$groups" | awk '{for(i=1;i<=NF;i++) printf "%s\"%s\"", (i>1?",":""), $i}')"
      exit 0
    else
      have_user "$USER" || die "User $USER not found."
      echo "User: $USER"
      echo "UID: $(id -u "$USER")"
      echo "Groups: $(id -nG "$USER")"
      if [[ -f "$HTPASSWD_FILE" ]] && grep -qE "^${USER}:" "$HTPASSWD_FILE"; then
        echo "htpasswd: present"
      else
        echo "htpasswd: absent"
      fi
      if command -v pdbedit >/dev/null 2>&1 && pdbedit -L 2>/dev/null | cut -d: -f1 | grep -qx "$USER"; then
        echo "samba: present"
      else
        echo "samba: absent"
      fi
      echo "Role (DB): $(db_get_role "$USER" || true)"
      exit 0
    fi
  ;;

  list-roles)
    for k in "${!ROLE_GROUPS[@]}"; do
      echo "$k => ${ROLE_GROUPS[$k]}"
    done
  ;;

  list-users)
    # prints all non-deleted users as JSON array
    if [[ ! -f "$DB_FILE" ]]; then
      echo "[]" ; exit 0
    fi
    # usernames are validated at creation, so safe to JSON-emit
    sqlite3 -separator $'\t' "$DB_FILE" \
      "SELECT username, role, created_at, updated_at, deleted_at FROM users WHERE deleted_at IS NULL ORDER BY username ASC" \
    | awk 'BEGIN{print "["}
           {
             u=$1; r=$2; ca=$3; ua=$4; da=$5;
             if(NR>1) printf ",";
             printf "{\"username\":\"%s\",\"role\":\"%s\",\"created_at\":%s,\"updated_at\":%s}", u, r, (ca?ca:"null"), (ua?ua:"null")
           }
           END{print "]"}'
    exit 0
  ;;

  *)
    usage; exit 1;;
esac
