From 98b50dbbf48a9537e6bd53de020ed3f9ff09a142 Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Thu, 9 Jul 2026 16:42:00 -0400 Subject: [PATCH] feat: ownership visibility helpers for config entities Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU --- hbd/server/config_access.py | 61 +++++++++++++++++++++++++++++++++++++ tests/test_config_access.py | 58 +++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 hbd/server/config_access.py create mode 100644 tests/test_config_access.py diff --git a/hbd/server/config_access.py b/hbd/server/config_access.py new file mode 100644 index 0000000..936e207 --- /dev/null +++ b/hbd/server/config_access.py @@ -0,0 +1,61 @@ +"""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) + } diff --git a/tests/test_config_access.py b/tests/test_config_access.py new file mode 100644 index 0000000..7e4c968 --- /dev/null +++ b/tests/test_config_access.py @@ -0,0 +1,58 @@ +"""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"}