feat: ownership visibility helpers for config entities
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"}
|
||||||
Reference in New Issue
Block a user