Compare commits

...
15 Commits
Author SHA1 Message Date
andreas 880dc0e33f version 5.3.12
Release / release (push) Successful in 47s
2026-07-09 20:43:59 -04:00
andreasandClaude Fable 5 f06b2ef9e8 fix: users loop clobbered requesting username in settings filtering
The users-section loop reused 'username' as its loop variable, overwriting
the requesting user's name so host/threshold filtering compared against the
last user in the config. Rename to 'uname' and add a regression test with a
user that is not last in the users dict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 18:48:05 -04:00
andreasandClaude Fable 5 447b9574a3 fix: type annotations for mypy parity with master
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 17:52:02 -04:00
andreasandClaude Fable 5 e98cca22f7 docs: ownership rules and settings access for owners/managers
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 17:50:24 -04:00
andreasandClaude Fable 5 5fe069a1a2 feat: retire per-channel private flag on profile page
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:53:55 -04:00
andreasandClaude Fable 5 ca2868a888 feat: settings UI renders per-user edit rights and owner controls
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:52:44 -04:00
andreasandClaude Fable 5 ddb1b2b875 feat: settings page accessible to all authenticated users
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:50:01 -04:00
andreasandClaude Fable 5 39c4d45bd7 feat: per-user filtering of settings sections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:48:58 -04:00
andreasandClaude Fable 5 07fefab861 feat: scoped non-admin saves through POST /api/0/config
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:46:47 -04:00
andreasandClaude Fable 5 6b4524b30d feat: threshold form payload carries per-config owner
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:46:10 -04:00
andreasandClaude Fable 5 a149a1e285 feat: channel ownership rule — owner-presence means private, admin promote/demote
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:45:21 -04:00
andreasandClaude Fable 5 dd939ab86e feat: scoped threshold-config merge for non-admin saves
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:43:56 -04:00
andreasandClaude Fable 5 5c6f462081 feat: scoped host merge for non-admin config saves
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:43:08 -04:00
andreasandClaude Fable 5 98b50dbbf4 feat: ownership visibility helpers for config entities
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
2026-07-09 16:42:00 -04:00
andreasandClaude Opus 4.8 960822918a fix: don't re-announce boot/shutdown on SIGHUP restart
Setting args.boot = False after sending the boot message was dead code: the
SIGHUP restart re-execs via os.execv(sys.argv[0], sys.argv) with the original
argv, so the re-launched process re-parsed -b and announced a boot again. The
exiting process also sent a spurious shutdown (send_shutdown armed by -b), so
each config reload looked like a host reboot to the server.

Strip -b/--boot from the argv passed to execv, and skip the shutdown message
in cleanup() when dorestart is set. A real SIGTERM/SIGINT shutdown still sends
shutdown as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 15:46:09 -04:00
18 changed files with 784 additions and 134 deletions
+21
View File
@@ -2,6 +2,27 @@
All notable changes to this project are documented here, organized by release.
## [5.3.12]
### Added
- retire per-channel private flag on profile page
- settings UI renders per-user edit rights and owner controls
- settings page accessible to all authenticated users
- per-user filtering of settings sections
- scoped non-admin saves through POST /api/0/config
- threshold form payload carries per-config owner
- channel ownership rule — owner-presence means private, admin promote/demote
- scoped threshold-config merge for non-admin saves
- scoped host merge for non-admin config saves
- ownership visibility helpers for config entities
### Fixed
- users loop clobbered requesting username in settings filtering
- type annotations for mypy parity with master
- don't re-announce boot/shutdown on SIGHUP restart
---
## [5.3.11]
### Added
+1 -1
View File
@@ -20,7 +20,7 @@ A lightweight UDP-based host monitoring system. Monitored hosts run a client (`h
└────────────────────┘ └────────────────────────────┘
```
**Package:** `hbd` v5.3.11
**Package:** `hbd` v5.3.12
**Python:** 3.11+
### Subpackages
+15 -13
View File
@@ -32,15 +32,16 @@ base_url: https://hbd.example.com
### Channel definitions
Channels are defined under `notification_channels`. Each entry specifies a delivery type and its credentials. Two optional metadata fields control visibility:
Channels are defined under `notification_channels`. Each entry specifies a delivery type and its credentials. Ownership is the single visibility signal:
| Field | Default | Description |
|---|---|---|
| `owner` | *(absent)* | Username who created/owns this channel. Absent = admin-created. |
| `private` | `false` | When `true`, only the owner can see and select this channel. |
| `owner` | *(absent)* | Owning username. Present = private to that user; absent = global. |
| `min_level` | `WARNING` | Minimum alert level this channel receives. |
**Admin-created channels** (set in the config file or via the admin settings UI) are public by default — all users can select them:
(The former `private` flag is retired; leftover `private` keys are ignored and dropped on the next edit.)
**Global channels** (no `owner`; set in the config file or by an admin) can be selected by all users but edited only by admins:
```yaml
notification_channels:
@@ -90,7 +91,7 @@ notification_channels:
username: heartbeat-bot
```
**User-created channels** are written by authenticated users through the API or their profile page. They carry an `owner` field and optionally `private: true`:
**User-created channels** are written by authenticated users through the API, their profile page, or the settings page. They carry an `owner` field and are private to that user:
```yaml
notification_channels:
@@ -99,17 +100,18 @@ notification_channels:
type: pushover
token: personal-token
user: personal-key
owner: alice # created by alice
private: true # only alice can see this channel
owner: alice # private to alice
```
### Channel visibility
| Channel | Who can see / select it |
|---|---|
| No `private` field (or `private: false`) | All users |
| `private: true` | Only the `owner` |
| Any channel | Admins always see everything |
| Channel | Who can see / select it | Who can edit it |
|---|---|---|
| No `owner` (global) | All users | Admins |
| `owner` set (private) | Only the `owner` | The owner |
| Any channel | Admins always see everything | Admins |
Admins can **promote** a private channel to global by clearing its owner on the settings page, or **demote** a global channel by assigning an owner.
### Users with notification channels
@@ -299,7 +301,7 @@ Called once at startup from `main.py`. Pass the running asyncio event loop so Ma
- Check that the host has an `owner` or `managers` set
- Check that users have `notification_channels` listed
- Check that the channel names in user config match keys under `notification_channels:`
- If a user can't select a channel, check whether it is `private: true` and owned by someone else
- If a user can't select a channel, check whether it has an `owner` other than that user
**min_level filtering too aggressive:**
- Default is `WARNING` — both WARNING and CRITICAL are sent
+10 -1
View File
@@ -19,6 +19,15 @@ Users are defined in the server config file. Each host can have an **owner**, ze
`admin` is a flag on the user, not a per-host role. An admin user has owner-level access on every host without being listed as owner/manager/monitor.
### Settings page access
All authenticated users may open `/settings`. Admins see every section; non-admins see only **Notification Channels**, **Hosts**, and **Threshold Configurations**, filtered to global items plus what they own or manage:
- **Owners** may add hosts, delete their hosts, edit host settings, and change access lists (managers/monitors, ownership transfer).
- **Managers** may edit host settings (watch, dyndns, channel and threshold assignments) but not access lists, and may not delete hosts.
- Anyone may create private notification channels and threshold configs (owned by them) and assign them — or global ones — to their hosts.
- Admins promote a private channel/threshold config to global by clearing its owner, or demote by assigning one.
---
## Configuration
@@ -200,7 +209,7 @@ Update the current user's profile. All fields are optional — send only what yo
```json
{ "notification_channels": ["pushover_ops", "email_ops"] }
```
Only channels visible to the user (public + own private) are accepted; others are silently dropped.
Only channels visible to the user (global + own) are accepted; others are silently dropped.
**Change password:**
```json
+1 -1
View File
@@ -14,4 +14,4 @@ Install options:
"""
__all__ = ["__version__"]
__version__ = "5.3.11"
__version__ = "5.3.12"
+6 -3
View File
@@ -486,7 +486,8 @@ async def cleanup(connections: List[AsyncConnection]):
logger.info("Cleaning up connections")
target = next((c for c in connections if c.transport), connections[0] if connections else None)
if target and send_shutdown:
# A SIGHUP restart is not a host shutdown, so don't announce one.
if target and send_shutdown and not dorestart:
try:
await target.sendto({"shutdown": 1, "acks": target.ackcount})
except Exception as e:
@@ -564,7 +565,6 @@ async def async_main(args, config):
boot_msg = {}
if args.boot:
boot_msg["boot"] = 1
args.boot = False # Clear boot flag so we don't send it again in main loop
send_shutdown = True
if args.message:
boot_msg["service"] = "service"
@@ -793,7 +793,10 @@ def main(argv=None):
# Handle restart
if dorestart:
logging.info("Restarting...")
os.execv(sys.argv[0], sys.argv)
# Drop -b/--boot so the re-exec'd process doesn't re-announce a boot;
# a SIGHUP restart is not a host reboot.
restart_argv = [sys.argv[0]] + [a for a in sys.argv[1:] if a not in ("-b", "--boot")]
os.execv(restart_argv[0], restart_argv)
sys.exit(exit_code)
+168
View File
@@ -0,0 +1,168 @@
"""Ownership rules for config-file entities (hosts, channels, threshold configs).
Rule: an entry whose config dict has a non-empty ``owner`` is private to that
owner visible, usable, and editable only by the owner (and admins). An
entry with no ``owner`` is global: usable by everyone, editable by admins only.
The scoped-merge helpers implement the non-admin save path for
``POST /api/0/config``: the caller's visible subset of a section is replaced
by the submitted payload; everything else is preserved untouched.
"""
from typing import Any, Dict
class ScopedMergeError(Exception):
"""A non-admin payload violated an ownership rule; message names the entry."""
def is_global(cfg: Any) -> bool:
"""True when *cfg* has no owner (usable by everyone)."""
return not (isinstance(cfg, dict) and cfg.get("owner"))
def user_can_use(cfg: Any, username: str) -> bool:
"""True when *username* may use/see this entry (global or own)."""
return is_global(cfg) or cfg.get("owner") == username
def _as_list(value: Any) -> list:
if value is None:
return []
if isinstance(value, str):
return [value]
return list(value)
def user_hosts(hosts_cfg: Any, username: str) -> Dict[str, Any]:
"""Subset of *hosts_cfg* where *username* is owner or manager."""
result = {}
for name, cfg in (hosts_cfg or {}).items():
if not isinstance(cfg, dict):
continue
if cfg.get("owner") == username or username in _as_list(cfg.get("managers")):
result[name] = cfg
return result
def user_channels(channels_cfg: Any, username: str) -> Dict[str, Any]:
"""Subset of channels usable by *username*: global + own."""
return {
name: cfg for name, cfg in (channels_cfg or {}).items()
if isinstance(cfg, dict) and user_can_use(cfg, username)
}
def user_threshold_configs(threshold_cfgs: Any, username: str) -> Dict[str, Any]:
"""Subset of threshold configs usable by *username*: global + own."""
return {
name: cfg for name, cfg in (threshold_cfgs or {}).items()
if isinstance(cfg, dict) and user_can_use(cfg, username)
}
_HOST_SETTING_KEYS = ("watch", "dyndns", "notification_channels", "threshold_config")
_HOST_ACCESS_KEYS = ("owner", "managers", "monitors")
_HOST_ALLOWED_KEYS = frozenset(_HOST_SETTING_KEYS) | frozenset(_HOST_ACCESS_KEYS)
def _check_added_assignments(host_name: str, old_cfg: Any, new_cfg: dict,
key: str, usable: set) -> None:
"""Every value under *key* not already on the host must be in *usable*."""
old_vals = set(_as_list((old_cfg or {}).get(key)))
for v in _as_list(new_cfg.get(key)):
if v not in old_vals and v not in usable:
raise ScopedMergeError(
f"host {host_name!r}: {key} entry {v!r} is not available to you")
def merge_hosts_scoped(existing: Any, payload: Any, username: str,
channels_cfg: Any, threshold_cfgs: Any) -> Dict[str, Any]:
"""Return a new hosts section with *username*'s visible subset replaced by *payload*.
Hosts the user cannot see are preserved untouched. Visible hosts missing
from the payload are deleted (owners only). Managers may change setting
keys but not access keys and may not delete. New hosts get their owner
forced to *username*. Newly added channel/threshold assignments must be
global or owned by the user. Violations raise ScopedMergeError.
"""
existing = existing or {}
payload = payload or {}
visible = user_hosts(existing, username)
usable_channels = set(user_channels(channels_cfg, username))
usable_tcs = set(user_threshold_configs(threshold_cfgs, username)) | {"default"}
result: Dict[str, Any] = {n: c for n, c in existing.items() if n not in visible}
for name, entry in payload.items():
if not isinstance(entry, dict):
raise ScopedMergeError(f"host {name!r}: invalid entry")
unknown = set(entry) - _HOST_ALLOWED_KEYS
if unknown:
raise ScopedMergeError(
f"host {name!r}: fields not permitted: {', '.join(sorted(unknown))}")
if name in existing and name not in visible:
raise ScopedMergeError(f"host {name!r}: you are not an owner or manager")
old = visible.get(name)
if old is None:
new_cfg = dict(entry)
new_cfg["owner"] = username
else:
new_cfg = dict(old)
for key in _HOST_SETTING_KEYS:
if key in entry:
new_cfg[key] = entry[key]
else:
new_cfg.pop(key, None)
if old.get("owner") == username:
for key in _HOST_ACCESS_KEYS:
if key in entry:
new_cfg[key] = entry[key]
else:
new_cfg.pop(key, None)
else:
if "owner" in entry and (entry.get("owner") or None) != (old.get("owner") or None):
raise ScopedMergeError(
f"host {name!r}: only the owner may change ownership")
for key in ("managers", "monitors"):
if key in entry and set(_as_list(entry[key])) != set(_as_list(old.get(key))):
raise ScopedMergeError(
f"host {name!r}: only the owner may change {key}")
_check_added_assignments(name, old, new_cfg, "notification_channels", usable_channels)
_check_added_assignments(name, old, new_cfg, "threshold_config", usable_tcs)
result[name] = new_cfg
for name, old in visible.items():
if name not in payload and old.get("owner") != username:
raise ScopedMergeError(f"host {name!r}: only the owner may delete a host")
return result
def merge_threshold_configs_scoped(existing: Any, payload: Any,
username: str) -> Dict[str, Any]:
"""Return a new threshold_configs section with the user's own configs
replaced by *payload*.
Global and foreign-owned configs are preserved and may not appear in the
payload ('default' included). Own configs missing from the payload are
deleted. Every payload entry gets its owner forced to *username*.
"""
existing = existing or {}
payload = payload or {}
result: Dict[str, Any] = {
n: c for n, c in existing.items()
if not (isinstance(c, dict) and c.get("owner") == username)
}
for name, entry in payload.items():
if name == "default":
raise ScopedMergeError("threshold config 'default' is global and admin-managed")
old = existing.get(name)
if old is not None and (not isinstance(old, dict) or old.get("owner") != username):
raise ScopedMergeError(f"threshold config {name!r}: not owned by you")
new_cfg = dict(entry) if isinstance(entry, dict) else {}
new_cfg["owner"] = username
result[name] = new_cfg
return result
+64 -32
View File
@@ -20,6 +20,7 @@ from . import users as users_mod
from . import oauth as oauth_mod
from . import ws as ws_mod
from . import configio as configio_mod
from . import config_access
logger = logging.getLogger(__name__)
@@ -27,19 +28,25 @@ eventlog = notify_mod.eventlog
def _build_threshold_configs_from_form(form_data: dict) -> dict:
"""Convert form-submitted flat threshold data to nested threshold_configs YAML structure.
"""Convert form-submitted threshold data to the nested threshold_configs structure.
Input: {config_name: {metric_path: {warning, critical, operator, hysteresis, enabled, count, display}}}
Output: {config_name: {thresholds: {plugin: {metric: {warning, critical, ...}}}}}
Input: {config_name: {owner?: str, metrics: {metric_path: {warning, critical, ...}}}}
Output: {config_name: {owner?: str, thresholds: {plugin: {metric: {...}}}}}
"""
result = {}
for config_name, metrics in form_data.items():
for config_name, cfg in form_data.items():
if not isinstance(cfg, dict):
continue
metrics = cfg.get("metrics")
if not isinstance(metrics, dict):
continue
thresholds = {}
thresholds: dict = {}
for metric_path, values in metrics.items():
_insert_threshold_metric(thresholds, metric_path, values)
result[config_name] = {"thresholds": thresholds}
entry = {"thresholds": thresholds}
if cfg.get("owner"):
entry["owner"] = cfg["owner"]
result[config_name] = entry
return result
@@ -1051,7 +1058,7 @@ async def start(
"name": name,
"type": cfg.get("type", ""),
"owner": cfg.get("owner"),
"private": bool(cfg.get("private", False)),
"private": not config_access.is_global(cfg),
}
for name, cfg in visible_channels.items()
if isinstance(cfg, dict)
@@ -1119,19 +1126,18 @@ async def start(
return web.Response(text=body, content_type="text/html")
# -------------------------------------------------------------------------
# Settings page (admin only)
# Settings page
# -------------------------------------------------------------------------
async def settings_page(request):
"""GET /settings — read-only view of the current server configuration."""
"""GET /settings — server configuration; non-admins see only what they own or manage."""
current_user, _ = _require_auth_redirect(request)
if current_user and not current_user.admin:
raise web.HTTPForbidden(reason="Admin access required")
pkg_dir = os.path.dirname(__file__)
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir), autoescape=True)
tmpl = env.get_template("settings.html")
settings_data = settings_mod.get_settings_data(config, threshold_checker=threshold_checker)
settings_data = settings_mod.get_settings_data(
config, threshold_checker=threshold_checker, user=current_user)
body = tmpl.render(
title="Settings - Heartbeat",
sections=settings_data["sections"],
@@ -1281,12 +1287,16 @@ async def start(
return web.json_response({"backups": backups})
async def api_config_post(request):
"""POST /api/0/config — publish staged changes to .hb.yaml. Admin only."""
"""POST /api/0/config — publish staged changes to .hb.yaml.
Admins may write any section. Non-admins may submit only 'hosts' and
'thresholds'; their payload is merged into the config scoped to the
entries they own or manage (see hbd.server.config_access).
"""
user, err = _require_auth(request)
if err:
return err
if user and not user.admin:
return web.json_response({"error": "Forbidden"}, status=403)
is_admin = user is None or user.admin
if not _config_path:
return web.json_response({"error": "Config path not available"}, status=503)
try:
@@ -1297,6 +1307,13 @@ async def start(
if not isinstance(payload, dict):
return web.json_response({"error": "Invalid JSON"}, status=400)
if not is_admin:
extra = set(payload) - {"hosts", "thresholds"}
if extra:
return web.json_response(
{"error": f"Not permitted to edit: {', '.join(sorted(extra))}"},
status=403)
try:
data = configio_mod.read_roundtrip(_config_path)
@@ -1345,18 +1362,36 @@ async def start(
if "thresholds" in payload:
tc = payload["thresholds"]
if isinstance(tc, str):
if not is_admin:
return web.json_response({"error": "Forbidden"}, status=403)
configio_mod.apply_yaml_section(data, "thresholds", tc)
elif isinstance(tc, dict):
data["threshold_configs"] = _build_threshold_configs_from_form(tc)
built = _build_threshold_configs_from_form(tc)
if is_admin:
data["threshold_configs"] = built
else:
data["threshold_configs"] = config_access.merge_threshold_configs_scoped(
data.get("threshold_configs") or {}, built, user.username)
if "hosts" in payload:
h = payload["hosts"]
if isinstance(h, dict):
if is_admin:
configio_mod.apply_structured_section(data, "hosts", h)
else:
merged = config_access.merge_hosts_scoped(
dict(data.get("hosts") or {}), h, user.username,
data.get("notification_channels") or {},
data.get("threshold_configs") or {})
configio_mod.apply_structured_section(data, "hosts", merged)
elif is_admin:
configio_mod.apply_yaml_section(data, "hosts", h)
else:
return web.json_response({"error": "Forbidden"}, status=403)
configio_mod.write_config(_config_path, data)
except config_access.ScopedMergeError as exc:
return web.json_response({"error": str(exc)}, status=403)
except Exception as exc:
logger.error("Config write failed: %s", exc)
return web.json_response({"error": str(exc)}, status=500)
@@ -1407,19 +1442,13 @@ async def start(
# -------------------------------------------------------------------------
def _visible_channels_for_user(user):
"""Return {name: cfg} of channels visible to user (public + own private)."""
"""Return {name: cfg} of channels visible to user (global + own)."""
all_channels = config.get("notification_channels") or {}
if user is None:
return {}
if user.admin:
return dict(all_channels)
visible = {}
for name, cfg in all_channels.items():
if not isinstance(cfg, dict):
continue
if not cfg.get("private") or cfg.get("owner") == user.username:
visible[name] = cfg
return visible
return config_access.user_channels(all_channels, user.username)
def _build_channel_response(ch_name, ch_cfg):
"""Serialize a channel config dict for the API response."""
@@ -1443,7 +1472,7 @@ async def start(
"type": ch_type,
"type_label": settings_mod._CHANNEL_TYPE_LABELS.get(ch_type, ch_type.title()),
"owner": ch_cfg.get("owner"),
"private": bool(ch_cfg.get("private", False)),
"private": not config_access.is_global(ch_cfg),
"min_level": ch_cfg.get("min_level", "WARNING"),
"fields": fields,
}
@@ -1508,9 +1537,12 @@ async def start(
if body.get("min_level"):
channel_cfg["min_level"] = body["min_level"]
if user.admin:
owner = (body.get("owner") or "").strip()
if owner:
channel_cfg["owner"] = owner
else:
channel_cfg["owner"] = user.username
if body.get("private"):
channel_cfg["private"] = True
try:
disk_data = configio_mod.read_roundtrip(_config_path)
@@ -1575,12 +1607,12 @@ async def start(
if body.get("min_level"):
channel_cfg["min_level"] = body["min_level"]
if owner is not None:
if user.admin:
new_owner = (body.get("owner") or "").strip() if "owner" in body else (owner or "")
if new_owner:
channel_cfg["owner"] = new_owner
elif owner is not None:
channel_cfg["owner"] = owner
if "private" in body:
channel_cfg["private"] = bool(body["private"])
elif existing_on_disk.get("private"):
channel_cfg["private"] = True
configio_mod.apply_channel(disk_data, ch_name, channel_cfg)
configio_mod.write_config(_config_path, disk_data)
+55 -16
View File
@@ -21,6 +21,8 @@ editable bool Reserved for future use — currently always False
sensitive bool True when the raw value must never be shown
"""
from . import config_access
# Credential field names that should always be masked.
_SECRET_KEYS = frozenset({
"password", "token", "user_key", "api_key", "secret",
@@ -140,9 +142,14 @@ def _sanitize_channel(name, cfg):
# Public API
# ---------------------------------------------------------------------------
def get_settings_sections(config: dict, threshold_checker=None) -> list:
def get_settings_sections(config: dict, threshold_checker=None, user=None) -> list:
"""Return ordered list of setting sections for the settings page.
*user* is an object with ``username``/``admin`` attributes, or None for
the unauthenticated (admin-equivalent) view. Non-admins get only the
channels/hosts/thresholds sections, filtered to global items plus what
they own or manage.
Each section:
{
"title": str,
@@ -162,6 +169,9 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
"sensitive": bool,
}
"""
is_admin = user is None or getattr(user, "admin", False)
username: str = getattr(user, "username", "") or ""
def field(key, label, ftype, description="", editable=False, sensitive=False):
raw = config.get(key)
if sensitive:
@@ -200,6 +210,8 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
for ch_name, ch_cfg in sorted((config.get("notification_channels") or {}).items()):
if not isinstance(ch_cfg, dict):
continue
if not is_admin and not config_access.user_can_use(ch_cfg, username):
continue
ch_type = ch_cfg.get("type", "")
fields = []
for k, v in ch_cfg.items():
@@ -219,18 +231,18 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
"type": ch_type,
"type_label": _CHANNEL_TYPE_LABELS.get(ch_type, ch_type.title()),
"owner": ch_cfg.get("owner"),
"private": bool(ch_cfg.get("private", False)),
"editable": is_admin or ch_cfg.get("owner") == username,
"min_level": ch_cfg.get("min_level", "WARNING"),
"fields": fields,
})
# ---- Users (show metadata only, never password hashes) ----------------
users_list = []
for username, attrs in (config.get("users") or {}).items():
for uname, attrs in (config.get("users") or {}).items():
if not isinstance(attrs, dict):
continue
users_list.append({
"username": username,
"username": uname,
"full_name": attrs.get("full_name", ""),
"admin": bool(attrs.get("admin", False)),
"avatar": attrs.get("avatar", ""),
@@ -252,9 +264,14 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
}
threshold_config_list = []
raw_threshold_cfgs = config.get("threshold_configs") or {}
if threshold_checker is not None:
if threshold_checker.threshold_configs:
for cfg_name, cfg_metrics in sorted(threshold_checker.threshold_configs.items()):
raw_cfg = raw_threshold_cfgs.get(cfg_name)
tc_owner = raw_cfg.get("owner") if isinstance(raw_cfg, dict) else None
if not is_admin and tc_owner and tc_owner != username:
continue
# For the default config use the merged effective set;
# for named overrides use only the explicitly defined metrics
# (threshold_raw_configs) so inherited defaults are not repeated.
@@ -266,25 +283,37 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
[_tc_to_row(tc) for tc in display_metrics.values()],
key=lambda m: m["metric"],
)
threshold_config_list.append({"name": cfg_name, "metrics": metrics})
threshold_config_list.append({
"name": cfg_name,
"metrics": metrics,
"owner": tc_owner,
"editable": is_admin or (tc_owner is not None and tc_owner == username),
})
elif threshold_checker.thresholds:
metrics = sorted(
[_tc_to_row(tc) for tc in threshold_checker.thresholds.values()],
key=lambda m: m["metric"],
)
threshold_config_list.append({"name": "default", "metrics": metrics})
threshold_config_list.append({"name": "default", "metrics": metrics,
"owner": None, "editable": is_admin})
# ---- Hosts summary ----------------------------------------------------
hosts_list = []
for hname, hcfg in sorted((config.get("hosts") or {}).items()):
if not isinstance(hcfg, dict):
continue
managers = hcfg.get("managers", [])
if isinstance(managers, str):
managers = [managers]
if not is_admin and hcfg.get("owner") != username and username not in managers:
continue
hosts_list.append({
"name": hname,
"watch": bool(hcfg.get("watch", True)),
"dyndns": bool(hcfg.get("dyndns", False)),
"owner": hcfg.get("owner", ""),
"managers": hcfg.get("managers", []),
"is_owner": is_admin or hcfg.get("owner") == username,
"managers": managers,
"monitors": hcfg.get("monitors", []),
"threshold_configs": (
list(v) if isinstance(v := hcfg.get("threshold_config"), list)
@@ -309,7 +338,7 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
"logo": pattrs.get("logo", ""),
})
return [
sections: list = [
{
"id": "network",
"title": "Network",
@@ -483,16 +512,26 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
},
]
if not is_admin:
sections = [s for s in sections if s["id"] in ("channels", "hosts", "thresholds")]
for s in sections:
s["fields"] = []
return sections
def get_settings_data(config: dict, threshold_checker=None) -> dict:
def get_settings_data(config: dict, threshold_checker=None, user=None) -> dict:
"""Return sections list + auxiliary data for the settings template."""
sections = get_settings_sections(config, threshold_checker=threshold_checker)
all_channel_names = sorted((config.get("notification_channels") or {}).keys())
all_usernames = sorted((config.get("users") or {}).keys())
all_threshold_configs = sorted((config.get("threshold_configs") or {}).keys())
sections = get_settings_sections(config, threshold_checker=threshold_checker, user=user)
is_admin = user is None or getattr(user, "admin", False)
username: str = getattr(user, "username", "") or ""
channels = config.get("notification_channels") or {}
threshold_cfgs = config.get("threshold_configs") or {}
if not is_admin:
channels = config_access.user_channels(channels, username)
threshold_cfgs = config_access.user_threshold_configs(threshold_cfgs, username)
return {
"sections": sections,
"all_channel_names": all_channel_names,
"all_usernames": all_usernames,
"all_threshold_configs": all_threshold_configs,
"all_channel_names": sorted(channels.keys()),
"all_usernames": sorted((config.get("users") or {}).keys()),
"all_threshold_configs": sorted(threshold_cfgs.keys()),
}
+2 -2
View File
@@ -6,12 +6,12 @@
<a href="/live"{% if active_page == "live" %} class="active"{% endif %}>Live Dashboard</a>
<a href="/plugins"{% if active_page == "plugins" %} class="active"{% endif %}>Host Overview</a>
<a href="/alerts"{% if active_page == "alerts" %} class="active"{% endif %}>Alerts</a>
{% if current_user and current_user.admin %}
{% if current_user %}
<a href="/settings"{% if active_page == "settings" %} class="active"{% endif %}>Settings</a>
{% endif %}
<a href="/about"{% if active_page == "about" %} class="active"{% endif %}>About</a>
</div>
{% if current_user and current_user.admin %}
{% if current_user %}
<button id="nav-publish-btn" class="nav-publish-btn" onclick="navPublishConfig()" style="display:none" title="Publish pending config changes to .hb.yaml">&#9888; Publish Config</button>
{% endif %}
<div class="nav-pie" title="Host alert status">
+2 -12
View File
@@ -240,7 +240,6 @@
}
.my-ch-name { font-weight: 600; font-size: .9em; color: #222; }
.my-ch-type { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #e8eaf6; color: #3949ab; }
.my-ch-private { padding: 2px 7px; border-radius: 8px; font-size: .72em; font-weight: 600; background: #fce4ec; color: #c62828; }
.my-ch-actions { margin-left: auto; display: flex; gap: 5px; }
.btn-sm-edit { background: #888; color: #fff; border: none; border-radius: 4px; padding: 2px 8px; font-size: .78em; cursor: pointer; }
.btn-sm-edit:hover { background: #666; }
@@ -465,7 +464,7 @@
{% if current_user %}
<div class="section">
<h2>My Channels</h2>
<p style="font-size:.82em;color:#888;margin:0 0 12px">Channels you own. Public channels are available to all users; private channels are visible only to you.</p>
<p style="font-size:.82em;color:#888;margin:0 0 12px">Channels you own are private to you. Global channels are managed by administrators.</p>
<div id="my-channels-list">
{% set my_channels = all_channels | selectattr('owner', 'equalto', current_user.username) | list %}
{% for ch in my_channels %}
@@ -473,7 +472,6 @@
<div class="my-ch-header">
<span class="my-ch-name">{{ ch.name | e }}</span>
<span class="my-ch-type">{{ ch.type | e }}</span>
{% if ch.private %}<span class="my-ch-private">private</span>{% endif %}
<span class="my-ch-actions">
<button class="btn-sm-edit" onclick="openMyChModal('{{ ch.name | e }}')">Edit</button>
<button class="btn-sm-del" onclick="deleteMyChannel('{{ ch.name | e }}')"></button>
@@ -513,11 +511,6 @@
<option value="CRITICAL">CRITICAL only</option>
</select>
</div>
<div class="ch-form-row">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="checkbox" id="my-ch-private"> Private — visible only to you
</label>
</div>
<div id="my-ch-modal-status" class="ch-modal-status"></div>
<div class="ch-modal-footer">
<button class="btn-save" style="background:#888" onclick="closeMyChModal()">Cancel</button>
@@ -744,7 +737,6 @@
document.getElementById('my-ch-type').value = '';
document.getElementById('my-ch-type-fields').innerHTML = '';
document.getElementById('my-ch-min-level').value = 'WARNING';
document.getElementById('my-ch-private').checked = false;
if (name) {
try {
@@ -755,7 +747,6 @@
document.getElementById('my-ch-type').value = ch.type;
onMyChTypeChange();
document.getElementById('my-ch-min-level').value = ch.min_level || 'WARNING';
document.getElementById('my-ch-private').checked = ch.private || false;
(ch.fields || []).forEach(f => {
const inp = document.getElementById('mychf-' + f.key);
if (inp) inp.value = f.value || '';
@@ -774,14 +765,13 @@
const name = document.getElementById('my-ch-name').value.trim();
const type = document.getElementById('my-ch-type').value;
const minLevel = document.getElementById('my-ch-min-level').value;
const isPrivate = document.getElementById('my-ch-private').checked;
const statusEl = document.getElementById('my-ch-modal-status');
statusEl.textContent = '';
if (!name) { statusEl.textContent = 'Name is required.'; statusEl.style.color = '#c62828'; return; }
if (!type) { statusEl.textContent = 'Please select a type.'; statusEl.style.color = '#c62828'; return; }
const body = { name, type, min_level: minLevel, private: isPrivate };
const body = { name, type, min_level: minLevel };
if (_myChSchemas[type]) {
(_myChSchemas[type].fields || []).forEach(sf => {
const inp = document.getElementById('mychf-' + sf.key);
+43 -13
View File
@@ -572,11 +572,12 @@
<option value="CRITICAL">CRITICAL only</option>
</select>
</div>
{% if not current_user or current_user.admin %}
<div class="ch-form-row">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="checkbox" id="ch-private"> Private — visible only to you
</label>
<label>Owner <span style="font-weight:normal;color:#888">(empty = global)</span></label>
<input type="text" id="ch-owner" placeholder="(global)" autocomplete="off">
</div>
{% endif %}
<div id="ch-modal-status" class="ch-status"></div>
<div class="ch-modal-footer">
<button class="btn btn-secondary" onclick="closeChannelModal()">Cancel</button>
@@ -594,8 +595,10 @@
{% for section in sections %}
<a href="#{{ section.id }}" onclick="closeSidebar()">{{ section.title }}</a>
{% endfor %}
{% if not current_user or current_user.admin %}
<hr style="margin: 8px 0; border: none; border-top: 1px solid #e8e8e8;">
<a href="#" onclick="showRollbackModal(); return false;" style="color:#888;font-size:.82em">View backups / rollback</a>
{% endif %}
</div>
</nav>
@@ -709,12 +712,18 @@
<td style="font-family:monospace;font-size:.9em;white-space:nowrap">{{ h.name | e }}</td>
<td style="text-align:center"><input type="checkbox" class="host-watch" {% if h.watch %}checked{% endif %}></td>
<td style="text-align:center"><input type="checkbox" class="host-dyndns" {% if h.dyndns %}checked{% endif %}></td>
{% if h.is_owner %}
<td><input class="field-input host-owner" value="{{ h.owner | e }}" placeholder="(none)" style="min-width:90px"></td>
<td>{{ mpick(all_usernames, h.managers, 'host-managers') }}</td>
<td>{{ mpick(all_usernames, h.monitors, 'host-monitors') }}</td>
{% else %}
<td><span class="val-tag">{{ h.owner | e }}</span></td>
<td>{% for m in h.managers %}<span class="val-tag">{{ m | e }}</span>{% else %}<span class="val-empty">(none)</span>{% endfor %}</td>
<td>{% for m in h.monitors %}<span class="val-tag">{{ m | e }}</span>{% else %}<span class="val-empty">(none)</span>{% endfor %}</td>
{% endif %}
<td>{{ mpick(all_threshold_configs, h.threshold_configs, 'host-tc') }}</td>
<td>{{ mpick(all_channel_names, h.notification_channels, 'host-channels') }}</td>
<td><button class="btn-danger" onclick="toggleDeleteRow(this)"></button></td>
<td>{% if h.is_owner %}<button class="btn-danger" onclick="toggleDeleteRow(this)"></button>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
@@ -748,12 +757,13 @@
<span class="channel-name-text">{{ ch.name | e }}</span>
<span class="ch-type-badge">{{ ch.type_label | e }}</span>
{% if ch.min_level and ch.min_level != 'WARNING' %}<span class="ch-level-badge">{{ ch.min_level | e }}+</span>{% endif %}
{% if ch.private %}<span class="ch-private-badge">private</span>{% endif %}
{% if ch.owner %}<span class="ch-owner-badge">{{ ch.owner | e }}</span>{% endif %}
{% if ch.editable %}
<span class="channel-header-actions">
<button class="btn btn-secondary" style="font-size:.78em;padding:2px 8px" onclick="openChannelModal('{{ ch.name | e }}')">Edit</button>
<button class="btn-danger" onclick="deleteChannel('{{ ch.name | e }}')"></button>
</span>
{% endif %}
</div>
<div class="channel-fields">
{% for f in ch.fields %}
@@ -790,13 +800,20 @@
{% endfor %}
<div id="thresh-cfgs-{{ section.id }}" style="padding:8px 20px 0">
{% for tc in section.threshold_configs %}
<div class="thresh-cfg-card" data-config-name="{{ tc.name | e }}">
<div class="thresh-cfg-card" data-config-name="{{ tc.name | e }}"{% if not tc.editable %} data-readonly="true"{% endif %}>
<div class="thresh-cfg-header">
<span class="thresh-cfg-name-label">{{ tc.name | e }}</span>
{% if tc.name != 'default' %}
{% if (not current_user or current_user.admin) and tc.name != 'default' %}
<input type="text" class="field-input thresh-owner" value="{{ tc.owner or '' }}"
placeholder="(global)" title="Owner — empty = global" style="max-width:140px;margin-left:10px">
{% elif tc.owner %}
<span class="ch-owner-badge" style="margin-left:10px">{{ tc.owner | e }}</span>
{% endif %}
{% if tc.editable and tc.name != 'default' %}
<button class="btn-danger" style="margin-left:auto" onclick="deleteThresholdConfigCard(this)">✕ Delete</button>
{% endif %}
</div>
<fieldset {% if not tc.editable %}disabled{% endif %} style="border:none;margin:0;padding:0;min-width:0">
<div style="overflow-x:auto">
<table class="crud-table thresh-metric-table">
<thead><tr>
@@ -845,6 +862,7 @@
<button class="btn btn-secondary" style="font-size:.8em;padding:3px 10px"
onclick="addThresholdMetricRow(this.closest('.thresh-cfg-card').querySelector('tbody'))">+ Add metric</button>
</div>
</fieldset>
</div>
{% endfor %}
</div>
@@ -928,6 +946,7 @@
const _allChannels = {{ all_channel_names | tojson }};
const _allUsers = {{ all_usernames | tojson }};
const _allThresholdConfigs = {{ all_threshold_configs | tojson }};
const _isAdmin = {{ 'true' if (not current_user or current_user.admin) else 'false' }};
// ---- Channel CRUD ----
let _channelSchemas = {};
@@ -981,7 +1000,8 @@
document.getElementById('ch-type').value = '';
document.getElementById('ch-type-fields').innerHTML = '';
document.getElementById('ch-min-level').value = 'WARNING';
document.getElementById('ch-private').checked = false;
const ownerInp = document.getElementById('ch-owner');
if (ownerInp) ownerInp.value = '';
if (name) {
// Load existing channel data via API
@@ -993,7 +1013,7 @@
document.getElementById('ch-type').value = ch.type;
onChTypeChange();
document.getElementById('ch-min-level').value = ch.min_level || 'WARNING';
document.getElementById('ch-private').checked = ch.private || false;
if (ownerInp) ownerInp.value = ch.owner || '';
(ch.fields || []).forEach(f => {
const inp = document.getElementById('chf-' + f.key);
if (inp) inp.value = f.value || '';
@@ -1012,14 +1032,15 @@
const name = document.getElementById('ch-name').value.trim();
const type = document.getElementById('ch-type').value;
const minLevel = document.getElementById('ch-min-level').value;
const isPrivate = document.getElementById('ch-private').checked;
const statusEl = document.getElementById('ch-modal-status');
statusEl.textContent = '';
if (!name) { statusEl.textContent = 'Channel name is required.'; statusEl.style.color = '#c62828'; return; }
if (!type) { statusEl.textContent = 'Please select a type.'; statusEl.style.color = '#c62828'; return; }
const body = { name, type, min_level: minLevel, private: isPrivate };
const body = { name, type, min_level: minLevel };
const ownerInp = document.getElementById('ch-owner');
if (ownerInp) body.owner = ownerInp.value.trim();
if (_channelSchemas[type]) {
(_channelSchemas[type].fields || []).forEach(sf => {
const inp = document.getElementById('chf-' + sf.key);
@@ -1177,8 +1198,11 @@
watch: row.querySelector('.host-watch').checked,
dyndns: row.querySelector('.host-dyndns').checked,
};
const owner = row.querySelector('.host-owner').value.trim();
const ownerInput = row.querySelector('.host-owner');
if (ownerInput) {
const owner = ownerInput.value.trim();
if (owner) entry.owner = owner;
}
const managers = [...(row.querySelector('.host-managers')?.selectedOptions || [])].map(o => o.value);
if (managers.length) entry.managers = managers;
const monitors = [...(row.querySelector('.host-monitors')?.selectedOptions || [])].map(o => o.value);
@@ -1556,10 +1580,15 @@
const cfgsContainer = document.getElementById('thresh-cfgs-' + sectionId);
cfgsContainer.querySelectorAll('.thresh-cfg-card').forEach(card => {
if (card.dataset.readonly === 'true') return;
const configName = card.dataset.configName
|| (card.querySelector('.new-config-name')?.value || '').trim();
if (!configName) return;
configs[configName] = readMetrics(card);
const ownerInp = card.querySelector('.thresh-owner');
configs[configName] = {
owner: ownerInp ? ownerInp.value.trim() : '',
metrics: readMetrics(card),
};
});
_staged['thresholds'] = configs;
@@ -1613,6 +1642,7 @@
card.innerHTML = `
<div class="thresh-cfg-header">
<input type="text" class="field-input new-config-name" placeholder="Config name (e.g. servers)" style="max-width:220px">
${_isAdmin ? '<input type="text" class="field-input thresh-owner" placeholder="(global)" title="Owner — empty = global" style="max-width:140px;margin-left:10px">' : ''}
<button class="btn-danger" style="margin-left:auto" onclick="this.closest('.thresh-cfg-card').remove()">✕ Delete</button>
</div>
<div style="overflow-x:auto">
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hbd"
version = "5.3.11"
version = "5.3.12"
description = "Heartbeat monitoring system — client (hbc) and server (hbd)"
readme = "README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -41,7 +41,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
# updated by scripts/bumpminor.sh
__version__ = "5.3.11"
__version__ = "5.3.12"
# ---------------------------------------------------------------------------
# Protocol (mirrors hbd/common/proto.py)
+247
View File
@@ -0,0 +1,247 @@
"""Tests for ownership rules and scoped config merges (hbd.server.config_access)."""
import pytest
from hbd.server import config_access as ca
# ---------------------------------------------------------------------------
# Visibility helpers
# ---------------------------------------------------------------------------
def test_is_global_when_no_owner():
assert ca.is_global({"type": "pushover"})
assert ca.is_global({"type": "email", "owner": ""})
assert ca.is_global(None) # non-dict is treated as global
assert not ca.is_global({"type": "email", "owner": "alice"})
def test_user_can_use_global_or_own():
assert ca.user_can_use({"type": "pushover"}, "alice")
assert ca.user_can_use({"owner": "alice"}, "alice")
assert not ca.user_can_use({"owner": "bob"}, "alice")
HOSTS = {
"web1": {"owner": "alice", "watch": True},
"web2": {"owner": "bob", "managers": ["alice"]},
"web3": {"owner": "bob", "managers": "carol"}, # string manager form
"web4": {"watch": True}, # unowned
}
def test_user_hosts_owner_and_manager():
assert set(ca.user_hosts(HOSTS, "alice")) == {"web1", "web2"}
assert set(ca.user_hosts(HOSTS, "carol")) == {"web3"}
assert ca.user_hosts(HOSTS, "dave") == {}
assert ca.user_hosts(None, "alice") == {}
CHANNELS = {
"global_ch": {"type": "pushover"},
"alice_ch": {"type": "email", "owner": "alice"},
"bob_ch": {"type": "signal", "owner": "bob"},
}
def test_user_channels_global_plus_own():
assert set(ca.user_channels(CHANNELS, "alice")) == {"global_ch", "alice_ch"}
assert set(ca.user_channels(CHANNELS, "dave")) == {"global_ch"}
TCS = {
"default": {"thresholds": {}},
"alice_tc": {"owner": "alice", "thresholds": {}},
}
def test_user_threshold_configs_global_plus_own():
assert set(ca.user_threshold_configs(TCS, "alice")) == {"default", "alice_tc"}
assert set(ca.user_threshold_configs(TCS, "bob")) == {"default"}
# ---------------------------------------------------------------------------
# merge_hosts_scoped
# ---------------------------------------------------------------------------
MERGE_HOSTS = {
"mine": {"owner": "alice", "watch": True, "notification_channels": ["global_ch"]},
"managed": {"owner": "bob", "managers": ["alice"], "watch": True,
"notification_channels": ["bob_ch"]},
"foreign": {"owner": "bob", "watch": False},
}
MERGE_CHANNELS = {
"global_ch": {"type": "pushover"},
"alice_ch": {"type": "email", "owner": "alice"},
"bob_ch": {"type": "signal", "owner": "bob"},
}
MERGE_TCS = {"alice_tc": {"owner": "alice"}, "bob_tc": {"owner": "bob"}}
def _merge_hosts(payload, existing=None):
return ca.merge_hosts_scoped(
existing if existing is not None else dict(MERGE_HOSTS),
payload, "alice", MERGE_CHANNELS, MERGE_TCS,
)
def _full_payload(**overrides):
"""Payload covering alice's full visible subset, with per-host overrides."""
p = {
"mine": {"owner": "alice", "watch": True, "notification_channels": ["global_ch"]},
"managed": {"watch": True, "notification_channels": ["bob_ch"]},
}
p.update(overrides)
return p
def test_merge_hosts_preserves_foreign_hosts():
result = _merge_hosts(_full_payload())
assert result["foreign"] == {"owner": "bob", "watch": False}
def test_merge_hosts_manager_edits_settings():
result = _merge_hosts(_full_payload(managed={"watch": False, "dyndns": True,
"notification_channels": ["bob_ch"]}))
assert result["managed"]["watch"] is False
assert result["managed"]["dyndns"] is True
# access fields carried over untouched
assert result["managed"]["owner"] == "bob"
assert result["managed"]["managers"] == ["alice"]
def test_merge_hosts_manager_cannot_change_owner():
with pytest.raises(ca.ScopedMergeError, match="managed"):
_merge_hosts(_full_payload(managed={"watch": True, "owner": "alice"}))
def test_merge_hosts_manager_cannot_change_managers():
with pytest.raises(ca.ScopedMergeError, match="managers"):
_merge_hosts(_full_payload(managed={"watch": True, "managers": ["alice", "carol"]}))
def test_merge_hosts_manager_same_access_values_ok():
# Submitting unchanged access fields is not a violation.
result = _merge_hosts(_full_payload(managed={"watch": True, "owner": "bob",
"managers": ["alice"],
"notification_channels": ["bob_ch"]}))
assert result["managed"]["owner"] == "bob"
def test_merge_hosts_manager_cannot_delete():
payload = {"mine": {"owner": "alice", "watch": True}} # 'managed' missing
with pytest.raises(ca.ScopedMergeError, match="managed"):
_merge_hosts(payload)
def test_merge_hosts_owner_can_delete():
payload = _full_payload()
del payload["mine"]
result = _merge_hosts(payload)
assert "mine" not in result
assert "managed" in result and "foreign" in result
def test_merge_hosts_owner_can_transfer_ownership():
result = _merge_hosts(_full_payload(mine={"owner": "bob", "watch": True}))
assert result["mine"]["owner"] == "bob"
def test_merge_hosts_new_host_owner_forced():
result = _merge_hosts(_full_payload(newhost={"watch": True, "owner": "bob"}))
assert result["newhost"]["owner"] == "alice"
def test_merge_hosts_cannot_touch_foreign_host():
with pytest.raises(ca.ScopedMergeError, match="foreign"):
_merge_hosts(_full_payload(foreign={"watch": True}))
def test_merge_hosts_cannot_add_foreign_private_channel():
with pytest.raises(ca.ScopedMergeError, match="bob_ch"):
_merge_hosts(_full_payload(mine={"owner": "alice", "watch": True,
"notification_channels": ["bob_ch"]}))
def test_merge_hosts_keeps_preexisting_foreign_assignment():
# 'managed' already has bob_ch; keeping it is fine.
result = _merge_hosts(_full_payload())
assert result["managed"]["notification_channels"] == ["bob_ch"]
def test_merge_hosts_can_add_global_and_own_channel():
result = _merge_hosts(_full_payload(mine={"owner": "alice", "watch": True,
"notification_channels": ["global_ch", "alice_ch"]}))
assert result["mine"]["notification_channels"] == ["global_ch", "alice_ch"]
def test_merge_hosts_threshold_assignment_validated():
with pytest.raises(ca.ScopedMergeError, match="bob_tc"):
_merge_hosts(_full_payload(mine={"owner": "alice", "watch": True,
"threshold_config": ["bob_tc"]}))
result = _merge_hosts(_full_payload(mine={"owner": "alice", "watch": True,
"threshold_config": ["default", "alice_tc"]}))
assert result["mine"]["threshold_config"] == ["default", "alice_tc"]
def test_merge_hosts_rejects_unknown_fields():
with pytest.raises(ca.ScopedMergeError, match="sneaky"):
_merge_hosts(_full_payload(mine={"owner": "alice", "watch": True, "sneaky": 1}))
def test_merge_hosts_owner_clearing_list_removes_key():
result = _merge_hosts(_full_payload(mine={"owner": "alice", "watch": True}))
assert "notification_channels" not in result["mine"]
# ---------------------------------------------------------------------------
# merge_threshold_configs_scoped
# ---------------------------------------------------------------------------
MERGE_EXISTING_TCS = {
"default": {"thresholds": {"cpu": {"load": {"warning": 2}}}},
"alice_tc": {"owner": "alice", "thresholds": {"cpu": {"load": {"warning": 3}}}},
"bob_tc": {"owner": "bob", "thresholds": {}},
"global_tc": {"thresholds": {}},
}
def _merge_tcs(payload):
return ca.merge_threshold_configs_scoped(dict(MERGE_EXISTING_TCS), payload, "alice")
def test_merge_tcs_new_config_owner_forced():
result = _merge_tcs({"alice_tc": {"thresholds": {}},
"new_tc": {"thresholds": {}, "owner": "bob"}})
assert result["new_tc"]["owner"] == "alice"
def test_merge_tcs_edit_own():
result = _merge_tcs({"alice_tc": {"thresholds": {"mem": {"used": {"warning": 90}}}}})
assert result["alice_tc"]["thresholds"] == {"mem": {"used": {"warning": 90}}}
assert result["alice_tc"]["owner"] == "alice"
def test_merge_tcs_delete_own_when_missing():
result = _merge_tcs({})
assert "alice_tc" not in result
def test_merge_tcs_preserves_global_and_foreign():
result = _merge_tcs({"alice_tc": {"thresholds": {}}})
assert result["default"] == MERGE_EXISTING_TCS["default"]
assert result["bob_tc"] == MERGE_EXISTING_TCS["bob_tc"]
assert result["global_tc"] == MERGE_EXISTING_TCS["global_tc"]
def test_merge_tcs_rejects_default():
with pytest.raises(ca.ScopedMergeError, match="default"):
_merge_tcs({"alice_tc": {"thresholds": {}}, "default": {"thresholds": {}}})
def test_merge_tcs_rejects_global_name_collision():
with pytest.raises(ca.ScopedMergeError, match="global_tc"):
_merge_tcs({"alice_tc": {"thresholds": {}}, "global_tc": {"thresholds": {}}})
def test_merge_tcs_rejects_foreign_owned():
with pytest.raises(ca.ScopedMergeError, match="bob_tc"):
_merge_tcs({"alice_tc": {"thresholds": {}}, "bob_tc": {"thresholds": {}}})
+25
View File
@@ -171,3 +171,28 @@ def test_write_path_preserves_oauth_client_secret(tmp_path):
assert data2["oauth"]["gitea"]["client_secret"] == original_secret, (
f"Expected original secret preserved, got: {data2['oauth']['gitea']['client_secret']!r}"
)
# ---- threshold form payload shape ----
def test_build_threshold_configs_new_shape_with_owner():
form = {
"servers": {
"owner": "alice",
"metrics": {"cpu_monitor.load_15min": {"operator": ">", "warning": 4.0,
"critical": 8.0, "enabled": True}},
},
}
result = http._build_threshold_configs_from_form(form)
assert result["servers"]["owner"] == "alice"
assert result["servers"]["thresholds"]["cpu_monitor"]["load_15min"]["warning"] == 4.0
def test_build_threshold_configs_empty_owner_means_global():
form = {"servers": {"owner": "", "metrics": {"rtt": {"warning": 100.0}}}}
result = http._build_threshold_configs_from_form(form)
assert "owner" not in result["servers"]
def test_build_threshold_configs_ignores_entries_without_metrics():
assert http._build_threshold_configs_from_form({"bad": {"owner": "x"}}) == {}
+15 -23
View File
@@ -86,61 +86,53 @@ def test_delete_channel_persisted_after_write(tmp_path):
# ---------------------------------------------------------------------------
# Visibility logic (mirrors http.py _visible_channels_for_user)
# Visibility logic (owner-presence rule, hbd.server.config_access)
# ---------------------------------------------------------------------------
from hbd.server import config_access as ca # noqa: E402
def _visible(config, user):
"""Local copy of the visibility helper for unit testing without the HTTP layer."""
all_channels = config.get("notification_channels") or {}
if user.get("admin"):
return set(all_channels.keys())
username = user["username"]
return {
name for name, cfg in all_channels.items()
if isinstance(cfg, dict) and (not cfg.get("private") or cfg.get("owner") == username)
}
return set(ca.user_channels(all_channels, user["username"]))
CONFIG_VISIBILITY = {
"notification_channels": {
"pub_ch": {"type": "pushover", "token": "t", "user": "u"},
"alice_priv": {"type": "email", "owner": "alice", "private": True,
"alice_priv": {"type": "email", "owner": "alice",
"recipients": ["a@a.com"], "sender": "s@a.com", "smtp_server": "s"},
"bob_priv": {"type": "signal", "owner": "bob", "private": True,
"user": "+1", "recipient": "+2"},
"admin_owned": {"type": "pushover", "token": "t2", "user": "u2", "owner": "adminuser"},
"bob_priv": {"type": "signal", "owner": "bob", "user": "+1", "recipient": "+2"},
"stale_flag": {"type": "pushover", "token": "t2", "user": "u2", "private": True},
}
}
def test_public_channel_visible_to_all():
def test_global_channel_visible_to_all():
for uname in ("alice", "bob", "carol"):
user = {"username": uname, "admin": False}
assert "pub_ch" in _visible(CONFIG_VISIBILITY, user)
assert "pub_ch" in _visible(CONFIG_VISIBILITY, {"username": uname, "admin": False})
def test_private_channel_visible_only_to_owner():
def test_owned_channel_visible_only_to_owner():
alice = {"username": "alice", "admin": False}
bob = {"username": "bob", "admin": False}
carol = {"username": "carol", "admin": False}
assert "alice_priv" in _visible(CONFIG_VISIBILITY, alice)
assert "alice_priv" not in _visible(CONFIG_VISIBILITY, bob)
assert "alice_priv" not in _visible(CONFIG_VISIBILITY, carol)
assert "bob_priv" in _visible(CONFIG_VISIBILITY, bob)
assert "bob_priv" not in _visible(CONFIG_VISIBILITY, alice)
def test_admin_sees_all_channels():
admin = {"username": "adminuser", "admin": True}
visible = _visible(CONFIG_VISIBILITY, admin)
assert visible == {"pub_ch", "alice_priv", "bob_priv", "admin_owned"}
assert _visible(CONFIG_VISIBILITY, admin) == {"pub_ch", "alice_priv", "bob_priv", "stale_flag"}
def test_admin_owned_channel_is_public_by_default():
def test_stale_private_flag_without_owner_is_global():
"""Owner-presence is the single signal; a leftover private flag is ignored."""
alice = {"username": "alice", "admin": False}
assert "admin_owned" in _visible(CONFIG_VISIBILITY, alice)
assert "stale_flag" in _visible(CONFIG_VISIBILITY, alice)
# ---------------------------------------------------------------------------
+102 -10
View File
@@ -24,7 +24,7 @@ def test_sections_have_section_mode():
sections = settings_mod.get_settings_sections(CFG)
for s in sections:
assert "section_mode" in s, f"Section {s['id']} missing section_mode"
assert s["section_mode"] in ("form", "yaml", "channels", "hosts")
assert s["section_mode"] in ("form", "yaml", "channels", "hosts", "thresholds")
def test_sections_have_api_section():
@@ -42,15 +42,13 @@ def test_network_section_has_editable_fields():
assert len(editable) >= 2 # hbd_port, ws_port at minimum
def test_yaml_sections_have_correct_mode():
def test_thresholds_and_dns_section_modes():
sections = settings_mod.get_settings_sections(CFG)
yaml_sections = {s["id"]: s for s in sections if s["section_mode"] == "yaml"}
assert "channels" not in yaml_sections # now uses "channels" mode
assert "hosts" not in yaml_sections # now uses "hosts" mode
assert "thresholds" in yaml_sections
assert "dns" in yaml_sections
assert yaml_sections["thresholds"]["api_section"] == "thresholds"
assert yaml_sections["dns"]["api_section"] == "dns"
by_id = {s["id"]: s for s in sections}
assert by_id["thresholds"]["section_mode"] == "thresholds"
assert by_id["thresholds"]["api_section"] == "thresholds"
assert by_id["dns"]["section_mode"] == "form"
assert by_id["dns"]["api_section"] == "dns"
def test_hosts_section_uses_hosts_mode():
@@ -70,7 +68,7 @@ def test_channels_section_uses_channels_mode():
assert ch["name"] == "pushover_ops"
assert ch["type"] == "pushover"
assert "owner" in ch
assert "private" in ch
assert "editable" in ch
def test_channel_type_schemas_exported():
@@ -112,3 +110,97 @@ def test_users_section_has_user_list():
assert users_sec["users"][0]["username"] == "alice"
# Password hash never exposed
assert "password" not in users_sec["users"][0]
# ---------------------------------------------------------------------------
# Per-user filtering (owners/managers on the settings page)
# ---------------------------------------------------------------------------
from types import SimpleNamespace # noqa: E402
MULTI_CFG = {
**CFG,
"users": {
"alice": {"full_name": "Alice", "admin": True, "password": "x"},
"bob": {"full_name": "Bob", "admin": False, "password": "x"},
},
"notification_channels": {
"global_ch": {"type": "pushover", "token": "t", "user": "u"},
"bob_ch": {"type": "pushover", "token": "t", "user": "u", "owner": "bob"},
"carol_ch": {"type": "pushover", "token": "t", "user": "u", "owner": "carol"},
},
"threshold_configs": {
"bob_tc": {"owner": "bob", "thresholds": {}},
"carol_tc": {"owner": "carol", "thresholds": {}},
},
"hosts": {
"bobhost": {"owner": "bob"},
"managedhost": {"owner": "carol", "managers": ["bob"]},
"carolhost": {"owner": "carol"},
},
}
BOB = SimpleNamespace(username="bob", admin=False)
ADMIN = SimpleNamespace(username="alice", admin=True)
def test_nonadmin_sees_only_three_sections():
sections = settings_mod.get_settings_sections(MULTI_CFG, user=BOB)
assert [s["id"] for s in sections] == ["channels", "hosts", "thresholds"]
def test_nonadmin_sections_hide_admin_fields():
sections = settings_mod.get_settings_sections(MULTI_CFG, user=BOB)
for s in sections:
assert s["fields"] == []
def test_nonadmin_hosts_filtered_with_is_owner():
sections = settings_mod.get_settings_sections(MULTI_CFG, user=BOB)
hosts = next(s for s in sections if s["id"] == "hosts")["hosts"]
by_name = {h["name"]: h for h in hosts}
assert set(by_name) == {"bobhost", "managedhost"}
assert by_name["bobhost"]["is_owner"] is True
assert by_name["managedhost"]["is_owner"] is False
def test_nonadmin_channels_filtered_with_editable():
sections = settings_mod.get_settings_sections(MULTI_CFG, user=BOB)
chans = {c["name"]: c for c in next(s for s in sections if s["id"] == "channels")["channels"]}
assert set(chans) == {"global_ch", "bob_ch"}
assert chans["bob_ch"]["editable"] is True
assert chans["global_ch"]["editable"] is False
def test_admin_sees_everything_with_editable():
sections = settings_mod.get_settings_sections(MULTI_CFG, user=ADMIN)
ids = [s["id"] for s in sections]
assert "network" in ids and "users" in ids
chans = {c["name"]: c for c in next(s for s in sections if s["id"] == "channels")["channels"]}
assert set(chans) == {"global_ch", "bob_ch", "carol_ch"}
assert all(c["editable"] for c in chans.values())
def test_settings_data_pickers_filtered_for_nonadmin():
data = settings_mod.get_settings_data(MULTI_CFG, user=BOB)
assert data["all_channel_names"] == ["bob_ch", "global_ch"]
assert data["all_threshold_configs"] == ["bob_tc"]
def test_no_user_means_admin_view():
sections = settings_mod.get_settings_sections(MULTI_CFG) # auth disabled
assert len(sections) > 3
def test_filtering_unaffected_by_other_users_in_config():
"""The users-section loop must not clobber the requesting username (regression)."""
cfg = dict(MULTI_CFG)
cfg["users"] = {
"alice": {"full_name": "Alice", "admin": True, "password": "x"},
"bob": {"full_name": "Bob", "admin": False, "password": "x"},
"zed": {"full_name": "Zed", "admin": False, "password": "x"}, # bob is not last
}
sections = settings_mod.get_settings_sections(cfg, user=BOB)
hosts = {h["name"] for h in next(s for s in sections if s["id"] == "hosts")["hosts"]}
assert hosts == {"bobhost", "managedhost"}