Admin-scoping setup: show live extension list, auto-include owned DIDs
Two refinements to the per-admin extension scoping added last commit: - The setup prompt now shows the dashboard's current extensions (pulled from its own running /api/pstn-permissions, reusing list_extensions()'s already-correct pjsip.conf parsing instead of a second implementation in bash) before asking for each admin's list, with a real example built from actual extension numbers instead of a generic placeholder. Shown fresh for every admin added, one at a time. - An admin scoped to an extension now automatically sees that extension's directly-assigned personal DID's call/text history too, not just its internal activity — parse_pstn_calls()/parse_texts() key inbound rows by the DID that was dialed, not the owning extension, so without this a scoped admin would see their own extension's outbound calls but not inbound calls to their own number. New _dids_for_extensions()/ _admin_scope_for_calls() resolve this per-request from pstn-personal-dids.conf's direct (non-ring-group) owner field. Voicemail scoping is unaffected — a mailbox is always keyed by extension number regardless of which DID rang it. Also removed a dead DASHBOARD_ADMINS_HEADER Python constant left over from before the file-writing responsibility settled on the bash side only. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4k6J1qXXyYxhGEgnJaMvn
This commit is contained in:
@@ -177,7 +177,7 @@ install_security-dashboard() {
|
||||
echo ""
|
||||
local _reconf_admins=""
|
||||
prompt_yn "Reconfigure per-admin extension scoping (Calls & Texts / Voicemail)? (y/n):" "n" _reconf_admins
|
||||
[[ "$_reconf_admins" =~ ^[Yy]$ ]] && _secdash_configure_admin_scoping "$APP_DIR" "$SVC_USER"
|
||||
[[ "$_reconf_admins" =~ ^[Yy]$ ]] && _secdash_configure_admin_scoping "$APP_DIR" "$SVC_USER" "$DASHBOARD_PORT"
|
||||
return 0
|
||||
;;
|
||||
cancel)
|
||||
@@ -221,7 +221,7 @@ install_security-dashboard() {
|
||||
# it later (e.g. to add Basic Auth to an already-deployed dashboard)
|
||||
# without duplicating this logic — see that function for the rest.
|
||||
_secdash_configure_caddy "$DASHBOARD_PORT"
|
||||
_secdash_configure_admin_scoping "$APP_DIR" "$SVC_USER"
|
||||
_secdash_configure_admin_scoping "$APP_DIR" "$SVC_USER" "$DASHBOARD_PORT"
|
||||
|
||||
write_readme "$APP_DIR" << README_MD
|
||||
# Security Dashboard
|
||||
@@ -386,6 +386,14 @@ every other identity — an admin not yet listed, a typo'd username, or a
|
||||
request with no Authelia identity at all — sees nothing on those two tabs
|
||||
until they're added too. Edits take effect immediately, no restart needed.
|
||||
|
||||
The setup prompt shows the dashboard's current extensions (pulled live from
|
||||
its own \`/api/pstn-permissions\`) before asking for each admin's list, so
|
||||
you're picking from what's actually configured rather than typing numbers
|
||||
from memory. List extension numbers only — any personal DID directly
|
||||
assigned to one of an admin's extensions is included in their Calls & Texts
|
||||
view automatically (Voicemail is always per-extension, so DIDs don't apply
|
||||
there).
|
||||
|
||||
## Manage
|
||||
\`\`\`
|
||||
sudo systemctl status security-dashboard
|
||||
@@ -784,8 +792,33 @@ CADDYBLOCK
|
||||
# shown first so nothing is silently lost, but the operator re-enters
|
||||
# everyone they want kept). Reasonable for the two-or-three-admin case this
|
||||
# exists for; not built to scale past that.
|
||||
#
|
||||
# Extension list/name comes from the RUNNING dashboard's own
|
||||
# /api/pstn-permissions (curl to localhost — the systemd unit is already up
|
||||
# by the time either call site below runs) instead of re-parsing pjsip.conf
|
||||
# here in bash: this reuses list_extensions()'s already-correct parsing
|
||||
# (device-comment-then-[ext]-header, category tags, etc.) instead of a
|
||||
# second, divergence-prone implementation of the same logic in a different
|
||||
# language. Best-effort — if curl/python3 aren't available, or the
|
||||
# dashboard isn't reachable yet, this just falls back to a generic example
|
||||
# and the operator types extension numbers from memory instead.
|
||||
_secdash_list_extensions() {
|
||||
local port="$1"
|
||||
command -v curl >/dev/null 2>&1 && command -v python3 >/dev/null 2>&1 || return 0
|
||||
curl -s -m 3 "http://localhost:${port}/api/pstn-permissions" 2>/dev/null | python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
for e in data.get("extensions") or []:
|
||||
name = (e.get("name") or "").strip()
|
||||
print(e.get("ext", "") + (" " + name if name else ""))
|
||||
' 2>/dev/null
|
||||
}
|
||||
|
||||
_secdash_configure_admin_scoping() {
|
||||
local APP_DIR="$1" SVC_USER="$2"
|
||||
local APP_DIR="$1" SVC_USER="$2" DASHBOARD_PORT="$3"
|
||||
local ADMINS_FILE="$APP_DIR/dashboard-admins.conf"
|
||||
|
||||
echo ""
|
||||
@@ -830,6 +863,10 @@ _secdash_configure_admin_scoping() {
|
||||
; tabs (the default). Once ANY [username] section exists here, every OTHER
|
||||
; identity - including an unlisted admin, or a request with no Remote-User
|
||||
; at all - sees NOTHING on those two tabs. Fail closed, not fail open.
|
||||
;
|
||||
; List extension numbers only - any personal DID directly assigned to one of
|
||||
; an admin's extensions is automatically included in their Calls & Texts
|
||||
; view too (see _dids_for_extensions() in app.py), no need to list DIDs here.
|
||||
|
||||
HDR
|
||||
|
||||
@@ -843,7 +880,17 @@ HDR
|
||||
log_warning " Invalid username — letters, numbers, and . _ @ - only. Skipped."
|
||||
continue
|
||||
fi
|
||||
prompt_text " Extensions for $_USER (comma-separated, e.g. 101,102):" "" _EXTS
|
||||
|
||||
local _EXT_LIST _EXAMPLE
|
||||
_EXT_LIST="$(_secdash_list_extensions "$DASHBOARD_PORT")"
|
||||
if [[ -n "$_EXT_LIST" ]]; then
|
||||
echo " Current extensions:"
|
||||
echo "$_EXT_LIST" | sed 's/^/ /'
|
||||
_EXAMPLE="$(echo "$_EXT_LIST" | awk '{print $1}' | head -2 | paste -sd, -)"
|
||||
fi
|
||||
[[ -z "$_EXAMPLE" ]] && _EXAMPLE="101,102"
|
||||
|
||||
prompt_text " Extensions for $_USER (comma-separated, e.g. ${_EXAMPLE}) — includes any DID assigned to them automatically:" "" _EXTS
|
||||
_EXTS="$(echo "$_EXTS" | tr -d ' ')"
|
||||
local -a _EXT_ARR=() _VALID=()
|
||||
IFS=',' read -ra _EXT_ARR <<< "$_EXTS"
|
||||
@@ -1966,21 +2013,6 @@ def voicemail_audio_path(ext, msg):
|
||||
# need the full Extensions list and Security Log to do their job, but each
|
||||
# should only see the call/text history and voicemail of their own numbers.
|
||||
DASHBOARD_ADMINS_FILE = "/opt/security-dashboard/dashboard-admins.conf"
|
||||
DASHBOARD_ADMINS_HEADER = (
|
||||
"; Per-admin extension scoping for the Calls & Texts and Voicemail tabs.\n"
|
||||
"; Managed by 'sudo ./setup.sh security-dashboard' (reconfigure on an\n"
|
||||
"; existing install) - safe to edit by hand too, one [username] section\n"
|
||||
"; per admin with an extensions= list, username must match that admin's\n"
|
||||
"; Authelia login exactly (this dashboard reads it off the Remote-User\n"
|
||||
"; header Caddy's forward_auth/import authelia already injects).\n"
|
||||
";\n"
|
||||
"; Empty or missing file = scoping disabled, everyone sees everything on\n"
|
||||
"; both tabs (today's default, unchanged). Once ANY [username] section\n"
|
||||
"; exists here, every OTHER identity - including an unlisted admin, or a\n"
|
||||
"; request with no Remote-User at all - sees NOTHING on those two tabs.\n"
|
||||
"; Fail closed, not fail open: a typo'd or forgotten admin loses access\n"
|
||||
"; rather than silently gaining everyone else's.\n\n"
|
||||
)
|
||||
|
||||
|
||||
def _dashboard_admins_cp():
|
||||
@@ -1998,8 +2030,9 @@ def allowed_extensions_for_user(username):
|
||||
entirely (no [username] sections configured at all). Once any admin IS
|
||||
configured, every other identity (unrecognized username, or no
|
||||
Remote-User header at all) gets an EMPTY set back, never None, so a
|
||||
caller can't mistake "not found" for "unrestricted" — see the fail-
|
||||
closed reasoning in DASHBOARD_ADMINS_HEADER."""
|
||||
caller can't mistake "not found" for "unrestricted" — see the
|
||||
fail-closed reasoning in _secdash_configure_admin_scoping's comment
|
||||
(services/security-dashboard.sh), which writes this file's header."""
|
||||
cp = _dashboard_admins_cp()
|
||||
if not cp.sections():
|
||||
return None
|
||||
@@ -2018,6 +2051,32 @@ def _admin_scope_for_request(handler):
|
||||
return allowed_extensions_for_user(handler.headers.get("Remote-User", ""))
|
||||
|
||||
|
||||
def _dids_for_extensions(extensions):
|
||||
"""Personal DIDs DIRECTLY owned by any of the given extensions (owner
|
||||
== the bare extension number — not a "@RingGroup" owner, which is a
|
||||
separate, broader-membership case this deliberately doesn't expand
|
||||
into). An admin scoped to an extension automatically sees that
|
||||
extension's own DID's call/text history too, without it needing to be
|
||||
entered separately in dashboard-admins.conf."""
|
||||
return {d["did"] for d in list_personal_dids() if d["owner"] in extensions}
|
||||
|
||||
|
||||
def _admin_scope_for_calls(handler):
|
||||
"""Like _admin_scope_for_request, but expanded for Calls & Texts
|
||||
specifically: a PSTN row for an inbound call/text to a personal DID is
|
||||
keyed by the DID number, not the owning extension (see
|
||||
parse_pstn_calls()'s docstring) — without this expansion, an admin
|
||||
scoped to extension 101 would see 101's internal activity but not calls
|
||||
to 101's own personal DID, which isn't what "has these extensions"
|
||||
should mean. Voicemail has no equivalent: a mailbox is always keyed by
|
||||
extension number regardless of which DID rang it, so
|
||||
filter_voicemail_by_scope keeps using the plain extension set."""
|
||||
allowed = _admin_scope_for_request(handler)
|
||||
if allowed is None:
|
||||
return None
|
||||
return allowed | _dids_for_extensions(allowed)
|
||||
|
||||
|
||||
def filter_calls_by_scope(events, allowed):
|
||||
"""events is the parse_pstn_calls()/parse_texts() shape - both
|
||||
normalize to from/to fields regardless of direction, so one filter
|
||||
@@ -5931,9 +5990,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
elif self.path == "/api/security-events":
|
||||
self._json(parse_security_log())
|
||||
elif self.path == "/api/pstn-calls":
|
||||
self._json(filter_calls_by_scope(parse_pstn_calls(), _admin_scope_for_request(self)))
|
||||
self._json(filter_calls_by_scope(parse_pstn_calls(), _admin_scope_for_calls(self)))
|
||||
elif self.path == "/api/comms-texts":
|
||||
self._json(filter_calls_by_scope(parse_texts(), _admin_scope_for_request(self)))
|
||||
self._json(filter_calls_by_scope(parse_texts(), _admin_scope_for_calls(self)))
|
||||
elif self.path == "/api/decisions":
|
||||
self._json(get_decisions())
|
||||
elif self.path == "/api/asn-exempt":
|
||||
|
||||
Reference in New Issue
Block a user