#!/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"
| # | Change | User | Description | Committed | |
|---|---|---|---|---|---|
| #2 | 32553 | C. Thomas Tyler |
Added LICENSE file. Adjusted URLs replacing https://swarm.workshop with https://workshop Adusted path to LICENSE to refer to P4Sudo's own project license, not the SDP one. |
||
| #1 | 32547 | bot_Claude_Anthropic |
Add p4sudo.sh dispatcher and p4sudo-help.sh Core broker filter scripts: p4sudo.sh reads broker stdin, validates the requesting user, parses p4sudo.cfg authorization rules, and dispatches to command scripts or native p4 commands. p4sudo-help.sh handles p4 help sudo interception. #review-32548 @robert_cowham @tom_tyler |