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
This commit is contained in:
+33
-4
@@ -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):
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user