From 07fefab86105960b927a0e6fa4ba21913b1d52ed Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Thu, 9 Jul 2026 16:46:47 -0400 Subject: [PATCH] feat: scoped non-admin saves through POST /api/0/config Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NfPpSpccTWBfZg1FTveyaU --- hbd/server/http.py | 41 +++++++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/hbd/server/http.py b/hbd/server/http.py index 8ad3a56..71a3bf9 100644 --- a/hbd/server/http.py +++ b/hbd/server/http.py @@ -1288,12 +1288,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: @@ -1304,6 +1308,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) @@ -1352,18 +1363,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): - configio_mod.apply_structured_section(data, "hosts", h) - else: + 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)