#!/bin/bash
set -u

#==============================================================================
# Copyright and license info is available in the LICENSE file included with
# the Server Deployment Package (SDP), and also available online:
# https://workshop.perforce.com/projects/p4sudo/view/main/LICENSE
#------------------------------------------------------------------------------
# p4sudo.sh — P4Sudo core dispatcher
#
# Called by p4broker as a filter script when a user runs:
#     p4 sudo <subcommand> [args...]
#
# May also be invoked directly with -h, -man, or -V.
#
# IMPORTANT: When called by p4broker, stdout is the broker protocol channel.
# SDP-style exec-redirection of stdout to a log file is therefore NOT used.
# All operational log output is written explicitly via log_write().
#
# Deployment:   /p4/common/site/p4sudo/p4sudo.sh
# Config:       ${P4SUDO_CFG:-/p4/common/site/config/p4sudo.cfg}
# Broker rule:
#   command: ^(sudo)$
#   {
#       action  = filter;
#       execute = "/p4/common/site/p4sudo/p4sudo.sh";
#   }
#
# See doc/broker-rewrite-reference/README.md for the full broker protocol.

#==============================================================================
# Declarations and Environment

declare ThisScript=${0##*/}
declare Version=1.0.0
declare ThisUser=
declare Args="$*"
declare CmdLine="$0 $Args"
declare ThisHost=${HOSTNAME%%.*}
declare -i Debug=${SDP_DEBUG:-0}
declare -i ErrorCount=0
declare -i WarningCount=0
declare -i LogIdx=0
declare Log=
declare LogTimestamp=
declare OldLogTimestamp=
declare LogLink=
declare H1="=============================================================================="

declare SDPInstance=${SDP_INSTANCE:-1}
declare SDPRoot=${SDP_ROOT:-/p4}
declare SDPCommon="$SDPRoot/common"
declare SDPCommonLib="$SDPCommon/lib"

# LOGS is normally set by p4_vars; derive from SDP structure as a fallback.
declare LOGS="${LOGS:-$SDPRoot/$SDPInstance/logs}"

# P4Sudo config file. Override via the P4SUDO_CFG environment variable.
declare P4SudoCfg="${P4SUDO_CFG:-/p4/common/site/config/p4sudo.cfg}"

# Audit log path — populated from p4sudo.cfg by parse_cfg().
declare AuditLog=

# Broker context — populated from stdin in Main Program.
declare -A BrokerCtx
declare -a BrokerArgs=()
declare -i ArgCount=0
declare Subcmd=
declare -a SubcmdArgs=()
declare RequestingUser=

# P4Sudo config values — populated by parse_cfg().
declare CmdDir=
declare P4SudoUser=
declare -i MaxArgs=20
declare -i ScriptTimeout=300
declare -A CmdType
declare -A CmdScript
declare -a Rules=()

# Authorization.
declare MatchedAction=
declare ArgsStr=

#==============================================================================
# Local Functions

function msg     () { echo -e "$*"; }
function dbg     () { [[ "$Debug" -eq 0 ]] || log_write "DEBUG" "$*"; }
function errmsg  () { msg "\nError: ${1:-Unknown Error}\n"; ErrorCount+=1; }
function warnmsg () { msg "\nWarning: ${1:-Unknown Warning}\n"; WarningCount+=1; }
function bail    () { errmsg "${1:-Unknown Error}"; exit "${2:-1}"; }

#------------------------------------------------------------------------------
# Function: log_write
#
# Write a timestamped entry to the per-invocation log file.
# Stdout is NOT used — it is reserved for the broker protocol.
#
# Input:
# $1 - level:   Log level label (INFO, WARN, ERROR, DEBUG, AUDIT).
# $2+ - message: Message text.
#------------------------------------------------------------------------------
function log_write ()
{
   local level="${1:-INFO}"; shift
   [[ -n "$Log" && "$Log" != off ]] && \
      echo "$(date '+%Y-%m-%dT%H:%M:%S') [$level] $*" >> "$Log"
}

#------------------------------------------------------------------------------
# Function: audit_write
#
# Write a timestamped entry to both the audit log and the per-invocation log.
# The audit log is the permanent record of all allow/deny decisions.
#
# Input:
# $* - message: Message text.
#------------------------------------------------------------------------------
function audit_write ()
{
   [[ -n "$AuditLog" ]] && echo "$(date '+%Y-%m-%dT%H:%M:%S') $*" >> "$AuditLog"
   log_write "AUDIT" "$*"
}

#------------------------------------------------------------------------------
# Function: parse_cfg
#
# Reads P4SudoCfg and populates global config variables.
#
# Populates: CmdDir, AuditLog, P4SudoUser, MaxArgs, ScriptTimeout, Debug,
#            CmdType[], CmdScript[], Rules[].
# Also sets: Log (ops log from config) if not already set via -L option.
#------------------------------------------------------------------------------
function parse_cfg ()
{
   local section=
   local key=
   local val=
   local cmdName=
   local attr=

   while IFS= read -r line || [[ -n "$line" ]]; do
      line="${line%%#*}"
      line="${line#"${line%%[![:space:]]*}"}"
      line="${line%"${line##*[![:space:]]}"}"
      [[ -z "$line" ]] && continue

      if [[ "$line" =~ ^\[([a-z]+)\]$ ]]; then
         section="${BASH_REMATCH[1]}"
         continue
      fi

      case "$section" in
         settings)
            key="${line%%=*}"; key="${key%"${key##*[![:space:]]}"}"
            val="${line#*=}";  val="${val#"${val%%[![:space:]]*}"}"
            case "$key" in
               command_dir)    CmdDir="$val";;
               log)            [[ -z "$Log" ]] && Log="$val";;
               audit_log)      AuditLog="$val";;
               p4sudo_user)    P4SudoUser="$val";;
               max_args)       MaxArgs="$val";;
               script_timeout) ScriptTimeout="$val";;
               debug)          [[ "$val" == true ]] && Debug=1;;
            esac
            ;;
         commands)
            key="${line%%=*}"; key="${key%"${key##*[![:space:]]}"}"
            val="${line#*=}";  val="${val#"${val%%[![:space:]]*}"}"
            cmdName="${key%%.*}"
            attr="${key#*.}"
            case "$attr" in
               type)   CmdType["$cmdName"]="$val";;
               script) CmdScript["$cmdName"]="$val";;
               # description, usage, ui_def: used by p4sudo-help.sh and web app.
            esac
            ;;
         rules)
            Rules+=("$line")
            ;;
      esac
   done < "$P4SudoCfg"
}

#------------------------------------------------------------------------------
# Function: user_in_group
#
# Returns 0 if RequestingUser is a member of the named P4 group, 1 otherwise.
# Uses P4SudoUser credentials to avoid requiring the requesting user to have
# list access on p4 groups.
#
# Input:
# $1 - group: P4 group name to test.
#------------------------------------------------------------------------------
function user_in_group ()
{
   local group="$1"
   local p4port="${BrokerCtx[p4port]:-}"
   local portFlag=
   [[ -n "$p4port" ]] && portFlag="-p $p4port"
   # shellcheck disable=SC2086
   p4 $portFlag -u "$P4SudoUser" groups -u "$RequestingUser" 2>/dev/null \
      | grep -qx "$group"
}

#------------------------------------------------------------------------------
# Function: check_authorization
#
# Walks the Rules array top-to-bottom.  Sets global MatchedAction to ALLOW
# or DENY on first matching rule.  If no rule matches, MatchedAction is left
# empty (caller treats this as an implicit deny).
#
# Reads globals:  RequestingUser, Subcmd, ArgsStr, Rules[].
# Sets global:    MatchedAction.
#------------------------------------------------------------------------------
function check_authorization ()
{
   local rAction='' rPrincipal='' rCommand='' rArgpat=''
   local ruleEntry=''
   local principalType='' principalName=''
   local -i principalMatched=0

   for ruleEntry in "${Rules[@]}"; do
      read -r rAction rPrincipal rCommand rArgpat <<< "$ruleEntry" || true
      rArgpat="${rArgpat:-}"

      # Match command (wildcard or exact).
      [[ "$rCommand" == "*" || "$rCommand" == "$Subcmd" ]] || continue

      # Match principal.
      principalType="${rPrincipal%%:*}"
      principalName="${rPrincipal#*:}"
      principalMatched=0

      case "$principalType" in
         user)
            [[ "$principalName" == "$RequestingUser" ]] && principalMatched=1
            ;;
         group)
            if user_in_group "$principalName"; then
               principalMatched=1
            fi
            ;;
         *)
            log_write "WARN" "Unknown principal type '$principalType' in rule: $ruleEntry"
            continue
            ;;
      esac

      [[ $principalMatched -eq 1 ]] || continue

      # Match arg pattern (if specified).
      if [[ -n "$rArgpat" ]]; then
         if [[ "$rArgpat" == "NOARGS" ]]; then
            [[ -z "$ArgsStr" ]] || continue
         else
            # Glob match against the normalized args string.
            # shellcheck disable=SC2254
            case "$ArgsStr" in
               $rArgpat) ;;
               *) continue;;
            esac
         fi
      fi

      # First match wins.
      MatchedAction="$rAction"
      return 0
   done
}

#==============================================================================
# Load SDP Library Functions.

if [[ -d "$SDPCommonLib" ]]; then
   # shellcheck disable=SC1090 disable=SC1091
   source "$SDPCommonLib/logging.lib" ||\
      bail "Failed to load bash lib [$SDPCommonLib/logging.lib]. Aborting."
   # shellcheck disable=SC1090 disable=SC1091
   source "$SDPCommonLib/run.lib" ||\
      bail "Failed to load bash lib [$SDPCommonLib/run.lib]. Aborting."
fi

# run.lib declares its own Version; restore this script's version.
declare Version=1.0.0

# Override terminate() from logging.lib: this script runs as a broker filter
# where stdout is the protocol channel.  The library's terminate() calls msg()
# to print "Log is: ...", which would corrupt the broker protocol response.
# This override writes that information to the log file instead.
# shellcheck disable=SC2317
function terminate ()
{
   trap - EXIT SIGINT SIGTERM
   log_write "INFO" "Exiting. ErrorCount=$ErrorCount Log=$Log"
   exit "$ErrorCount"
}

#------------------------------------------------------------------------------
# Function: usage (required function)
#
# Input:
# $1 - style: -h (short form) or -man (man-page form). Default: -h.
# $2 - usageErrorMessage: Optional; displayed before the usage text.
#------------------------------------------------------------------------------
function usage ()
{
   local style=${1:--h}
   local usageErrorMessage=${2:-Unset}

   if [[ "$usageErrorMessage" != Unset ]]; then
      msg "\n\nUsage Error:\n\n$usageErrorMessage\n\n"
   fi

   msg "USAGE for $ThisScript v$Version:

$ThisScript [-L <log>] [-d|-D]

or

$ThisScript [-h|-man|-V]
"
   if [[ "$style" == -man ]]; then
      msg "
DESCRIPTION:
   $ThisScript is the P4Sudo core dispatcher.  In normal operation it is
   invoked by p4broker (with no command-line arguments) as a filter script
   for the 'sudo' command.  All context is delivered via broker stdin.

   It may also be run directly with -h, -man, or -V.

   For each invocation the script:
     1. Parses broker stdin to identify the requesting user and arguments.
     2. Reads p4sudo.cfg for authorization rules and command registry.
     3. Checks authorization (top-to-bottom rules, first match wins).
     4. Dispatches to a command script (type=script), issues a REWRITE to
        p4d as the service account (type=native), or rejects with an error.

   All output goes to two places:
     - stdout (broker protocol channel) for messages returned to the p4 client.
     - A per-invocation log file in \$LOGS for the operational record.
     - A persistent audit log (path from p4sudo.cfg) for allow/deny decisions.

OPTIONS:
 -L <log>
   Specify the path to a log file, or 'off' to disable logging.  By default
   the log is written to \$LOGS/${ThisScript%.sh}.<timestamp>.log.
   The P4Sudo audit log is always written to the path in p4sudo.cfg,
   regardless of -L.

 -d
   Enable debug logging (written to the per-invocation log, not stdout).

 -D
   Enable extreme debug mode (bash set -x).

HELP OPTIONS:
 -h    Display short help message.
 -man  Display man-style help message.
 -V    Display version info for this script.

ENVIRONMENT:
 P4SUDO_CFG
   Override the default p4sudo.cfg path
   (default: /p4/common/site/config/p4sudo.cfg).

 SDP_ROOT      Override the SDP root directory (default: /p4).
 SDP_INSTANCE  Override the SDP instance number (default: 1).
 SDP_DEBUG     Set to 1 to enable debug logging at startup.

FILES:
 \${P4SUDO_CFG:-/p4/common/site/config/p4sudo.cfg}
   Authorization rules and P4Sudo settings.

 \$LOGS/${ThisScript%.sh}.<timestamp>.log
   Per-invocation operational log.

SEE ALSO:
 p4sudo-help.sh(1), doc/broker-rewrite-reference/README.md
"
   fi

   exit 2
}

#==============================================================================
# Command Line Processing
# In normal broker operation, no command-line arguments are provided.
# This block handles direct invocation for help, version checks, and debug.

declare -i ShiftArgs=0

set +u
while [[ $# -gt 0 ]]; do
   case $1 in
      (-h)           usage -h;;
      (-man|--help)  usage -man;;
      (-V|--version) msg "$ThisScript version $Version"; exit 0;;
      (-L)           Log="$2"; ShiftArgs=1;;
      (-d)           Debug=1;;
      (-D)           Debug=1; set -x;;
      (*)            usage -h "Unknown arg ($1).";;
   esac

   shift; while [[ $ShiftArgs -gt 0 ]]; do
      [[ $# -eq 0 ]] && usage -h "Incorrect number of arguments."
      ShiftArgs=$ShiftArgs-1
      shift
   done
done
set -u

#==============================================================================
# Command Line Verification
# (No additional verification required for the options above.)

#==============================================================================
# Main Program

trap terminate EXIT SIGINT SIGTERM

# Logging setup.
# NOTE: Unlike typical SDP scripts, stdout must not be redirected here — it
# is the broker protocol channel.  Logs are written explicitly via log_write()
# rather than via exec redirection.
if [[ "$Log" != off ]]; then
   if [[ -z "$Log" ]]; then
      if [[ -d "${LOGS:-}" ]]; then
         LogTimestamp=$(date +'%Y-%m-%d-%H%M%S')
         Log="$LOGS/${ThisScript%.sh}.${LogTimestamp}.log"
         until ( set -C; : > "$Log" ) 2>/dev/null; do
            Log="$LOGS/${ThisScript%.sh}.${LogTimestamp}.${LogIdx}.log"
            LogIdx+=1
         done
      fi
      # If LOGS dir does not exist, Log stays empty and log_write() no-ops.
   fi

   if [[ -n "$Log" ]]; then
      LogLink="$LOGS/${ThisScript%.sh}.log"
      if [[ -e "$LogLink" ]]; then
         if [[ -L "$LogLink" ]]; then
            rm -f "$LogLink"
         else
            OldLogTimestamp=$(get_old_log_timestamp "$LogLink")
            mv -f "$LogLink" "${LogLink%.log}.${OldLogTimestamp}.log" ||\
               warnmsg "Could not move old log file aside: $LogLink"
         fi
      fi
      ln -sf "$Log" "$LogLink" ||\
         warnmsg "Could not create log symlink: $LogLink"
   fi
fi

ThisUser=$(id -n -u)
log_write "INFO" "$H1"
log_write "INFO" "Starting $ThisScript v$Version as $ThisUser@$ThisHost on $(date)"
log_write "INFO" "CmdLine: $CmdLine"

#------------------------------------------------------------------------------
# Parse broker stdin.
# Fields: command, user, workspace, cwd, brokerTargetPort, argCount, Arg0..N

while IFS= read -r line; do
   [[ -z "$line" ]] && break
   key="${line%%: *}"
   value="${line#*: }"
   case "$key" in
      command)          BrokerCtx[command]="$value";;
      user)             BrokerCtx[user]="$value";;
      workspace)        BrokerCtx[workspace]="$value";;
      cwd)              BrokerCtx[cwd]="$value";;
      brokerTargetPort) BrokerCtx[p4port]="$value";;
      argCount)         ArgCount="$value";;
      Arg*)             BrokerArgs+=("$value");;
   esac
done

dbg "stdin: user=${BrokerCtx[user]:-} workspace=${BrokerCtx[workspace]:-} argCount=$ArgCount"

#------------------------------------------------------------------------------
# Require at least one argument (the subcommand name).

if (( ArgCount == 0 )); then
   msg "Usage: p4 sudo <subcommand> [args...]"
   msg "       p4 help sudo"
   exit 0
fi

Subcmd="${BrokerArgs[0]}"
SubcmdArgs=("${BrokerArgs[@]:1}")
RequestingUser="${BrokerCtx[user]:-}"

if [[ -z "$RequestingUser" ]]; then
   msg "p4sudo: error: could not determine requesting user from broker context."
   log_write "ERROR" "Could not determine requesting user from broker context."
   exit 1
fi

#------------------------------------------------------------------------------
# Read and validate p4sudo.cfg.

[[ -f "$P4SudoCfg" ]] || {
   msg "p4sudo: error: configuration file not found: $P4SudoCfg"
   log_write "ERROR" "Configuration file not found: $P4SudoCfg"
   exit 1
}

parse_cfg
dbg "config: CmdDir='$CmdDir' P4SudoUser='$P4SudoUser' rules=${#Rules[@]}"

if [[ -z "$P4SudoUser" ]]; then
   msg "p4sudo: error: p4sudo_user is not set in $P4SudoCfg"
   log_write "ERROR" "p4sudo_user not set in $P4SudoCfg"
   exit 1
fi

# Prevent privilege escalation: the service account may not invoke p4 sudo.
if [[ "$RequestingUser" == "$P4SudoUser" ]]; then
   audit_write "DENY user=$RequestingUser cmd=sudo $Subcmd (service account blocked)"
   msg "p4sudo: permission denied: the service account '$P4SudoUser' may not invoke p4 sudo."
   exit 1
fi

# Enforce max_args limit.
if (( ArgCount > MaxArgs )); then
   msg "p4sudo: error: too many arguments ($ArgCount > $MaxArgs)."
   log_write "ERROR" "Too many arguments: $ArgCount > $MaxArgs (user=$RequestingUser)"
   exit 1
fi

#------------------------------------------------------------------------------
# Authorization check.

ArgsStr=
[[ ${#SubcmdArgs[@]} -gt 0 ]] && ArgsStr="${SubcmdArgs[*]}"

check_authorization

if [[ "$MatchedAction" == "ALLOW" ]]; then
   : # authorized — fall through to dispatch
elif [[ "$MatchedAction" == "DENY" ]]; then
   audit_write "DENY user=$RequestingUser cmd=sudo $Subcmd${ArgsStr:+ $ArgsStr} (explicit DENY rule)"
   msg "p4sudo: permission denied: $RequestingUser is not authorized to run 'p4 sudo $Subcmd'."
   exit 1
else
   # No matching rule — implicit deny.
   audit_write "DENY user=$RequestingUser cmd=sudo $Subcmd${ArgsStr:+ $ArgsStr} (no matching rule)"
   msg "p4sudo: permission denied: $RequestingUser is not authorized to run 'p4 sudo $Subcmd'."
   exit 1
fi

audit_write "ALLOW user=$RequestingUser cmd=sudo $Subcmd${ArgsStr:+ $ArgsStr}"

#------------------------------------------------------------------------------
# Dispatch.

declare CmdTypeVal="${CmdType[$Subcmd]:-}"

case "$CmdTypeVal" in

   ""|native)
      # Unconfigured commands and native commands: rewrite to p4d as the
      # service account.
      log_write "INFO" "Dispatching native command '$Subcmd' for $RequestingUser"
      printf 'action: REWRITE\ncommand: %s\n' "$Subcmd"
      for arg in "${SubcmdArgs[@]+"${SubcmdArgs[@]}"}"; do
         printf 'arg: %s\n' "$arg"
      done
      ;;

   script)
      declare ScriptPath="${CmdScript[$Subcmd]:-}"
      if [[ -z "$ScriptPath" ]]; then
         msg "p4sudo: error: command '$Subcmd' is type=script but no script path is configured."
         log_write "ERROR" "No script path configured for command '$Subcmd'"
         exit 1
      fi
      if [[ ! -x "$ScriptPath" ]]; then
         msg "p4sudo: error: command script not found or not executable: $ScriptPath"
         log_write "ERROR" "Script not found or not executable: $ScriptPath"
         exit 1
      fi

      log_write "INFO" "Dispatching script '$Subcmd' -> $ScriptPath for $RequestingUser"

      # Export broker context so command scripts do not need to re-parse stdin.
      export P4SUDO_USER="$P4SudoUser"
      export P4SUDO_CFG="$P4SudoCfg"
      export P4SUDO_REQUESTING_USER="$RequestingUser"
      export P4SUDO_WORKSPACE="${BrokerCtx[workspace]:-}"
      export P4SUDO_CWD="${BrokerCtx[cwd]:-}"
      export P4SUDO_P4PORT="${BrokerCtx[p4port]:-}"

      declare -i ScriptRC=0
      timeout "$ScriptTimeout" "$ScriptPath" \
         "${SubcmdArgs[@]+"${SubcmdArgs[@]}"}" || ScriptRC=$?

      if (( ScriptRC == 124 )); then
         msg "p4sudo: error: command '$Subcmd' timed out after ${ScriptTimeout}s."
         log_write "ERROR" "Command '$Subcmd' timed out (${ScriptTimeout}s) for $RequestingUser"
         exit 1
      elif (( ScriptRC != 0 )); then
         log_write "WARN" "Command '$Subcmd' exited with status $ScriptRC for $RequestingUser"
         exit "$ScriptRC"
      fi
      ;;

   *)
      msg "p4sudo: error: unknown command type '$CmdTypeVal' for '$Subcmd' in $P4SudoCfg"
      log_write "ERROR" "Unknown command type '$CmdTypeVal' for '$Subcmd'"
      exit 1
      ;;
esac

log_write "INFO" "Completed: sudo $Subcmd${ArgsStr:+ $ArgsStr} for $RequestingUser"

exit "$ErrorCount"
