Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions admin/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,34 @@ async def api_broker_action(action: str, user: str = Depends(require_user)):
return supervisor.status()

# --- callers / policy / tokens -------------------------------------------
@app.get("/api/callers/{name}/settings")
async def api_get_settings(name: str, user: str = Depends(require_user)):
from .caller_settings import snapshot
from fastapi.responses import JSONResponse
config = broker_config.load()
try:
with open_store(config) as store:
result = snapshot(store, config, name)
result["operator"] = user
return JSONResponse(result, headers={"Cache-Control": "no-store"})
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc))

@app.put("/api/callers/{name}/settings")
async def api_set_settings(name: str, request: Request, user: str = Depends(require_user)):
from .caller_settings import apply, ChangedElsewhere
data = await _json_object(request)
config = broker_config.load()
try:
with open_store(config) as store:
return apply(store, config, name, data, user)
except ChangedElsewhere as exc:
raise HTTPException(status_code=409, detail=str(exc))
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))

@app.get("/api/callers")
async def api_callers(user: str = Depends(require_user)):
with open_store(broker_config.load()) as store:
Expand Down
65 changes: 65 additions & 0 deletions admin/caller_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""One settings snapshot and atomic mutation shared by HTML and native clients."""
import copy
import hashlib
import json

from broker import operations
from broker.registry import Registry


class ChangedElsewhere(ValueError):
pass


def snapshot(store, config, name):
caller = operations.require_caller(store, name)
if caller["revoked_at"] is not None:
raise LookupError("Caller is revoked")
catalog = {}
registry = Registry.from_sources(config.tools_root, config.tool_dirs)
for tool in registry.tool_ids():
catalog[tool] = []
for op in registry.list_ops():
catalog[op["tool"]].append({key: op[key] for key in ("op", "risk", "description")})
policy = store.policy_for(caller["id"])
installation = store.installation_id
material = [installation, caller["id"], policy, catalog]
revision = hashlib.sha256(json.dumps(material, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
return {"name": name, "caller_id": caller["id"], "installation": installation, "revision": revision,
"policy": policy, "enabled": operations.enabled_tools(policy), "catalog": catalog}


def apply(store, config, name, data, operator):
current = snapshot(store, config, name)
if data.get("revision") != current["revision"]:
raise ChangedElsewhere("Changed elsewhere. Compare latest before saving.")
catalog = current["catalog"]
enabled = data.get("enabled")
disabled = data.get("disabled_functions")
tools = data.get("tools")
if not isinstance(enabled, list) or not all(isinstance(t, str) and t in catalog for t in enabled):
raise ValueError("Invalid enabled tools")
if not isinstance(disabled, dict) or not isinstance(tools, dict):
raise ValueError("Invalid function settings")
# Send precisely the observed catalog. Never replace authority the editor could not see.
if set(disabled) != set(catalog) or set(tools) != set(catalog):
raise ChangedElsewhere("Function set changed. Compare latest before saving.")
after = copy.deepcopy(current["policy"])
after["enabled"] = sorted(set(current["enabled"]) - set(catalog) | set(enabled))
after.setdefault("tools", {})
after.setdefault("disabled_functions", {})
for tool, rows in catalog.items():
names = {op["op"] for op in rows}
excluded, effects = disabled[tool], tools[tool]
if (not isinstance(excluded, list) or not all(isinstance(op, str) and op in names for op in excluded)
or not isinstance(effects, dict) or set(effects) != names
or any(v not in ("allow", "review", "deny") for v in effects.values())):
raise ValueError("Invalid function settings")
old_excluded = set(after["disabled_functions"].get(tool, [])) - names
after["disabled_functions"][tool] = sorted(old_excluded | set(excluded))
after["tools"].setdefault(tool, {}).update(effects)
caller = operations.require_caller(store, name)
if not store.compare_and_set_policy(caller["id"], current["policy"], after, operator):
raise ChangedElsewhere("Changed elsewhere. Compare latest before saving.")
# Receipt is tied to the exact committed policy, not a later writer's snapshot.
return {"outcome": "saved", "name": name, "policy": after}
45 changes: 45 additions & 0 deletions admin/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,51 @@ async def clear_audit(request: Request):
return redirect("/#audit")

# --- policy ---------------------------------------------------------------
@app.get("/callers/{name}/settings")
async def edit_settings(request: Request, name: str):
from .caller_settings import snapshot
from .views.settings import settings_view
user = current_user(request)
if not user:
return redirect("/login")
try:
config = broker_config.load()
with open_store(config) as store:
state = snapshot(store, config, name)
state["csrf"] = csrf_for(request)
state["operator"] = user
if request.headers.get("accept") == "application/json":
return JSONResponse(state, headers={"Cache-Control": "no-store"})
return HTMLResponse(settings_view(user=user, csrf=csrf_for(request), caller=name, state=state),
headers={"Cache-Control": "no-store"})
except (LookupError, OSError, ValueError, sqlite3.Error):
if request.headers.get("accept") == "application/json":
return JSONResponse({"error": "Settings unavailable"}, status_code=503)
return HTMLResponse(settings_view(user=user, csrf=csrf_for(request), caller=name, state=None), status_code=503)

@app.post("/callers/{name}/settings")
async def save_settings(request: Request, name: str):
from .caller_settings import apply, ChangedElsewhere
user = current_user(request)
if not user:
return JSONResponse({"outcome": "rejected", "error": "Sign in before comparing and saving"}, status_code=401)
data = await read_form(request)
if not csrf_ok(request, data):
return JSONResponse({"outcome": "rejected", "error": "Session check failed. Compare latest."}, status_code=403)
try:
submitted = json.loads(data.get("settings_json", "null"))
if not isinstance(submitted, dict):
raise ValueError("Invalid settings")
config = broker_config.load()
with open_store(config) as store:
return JSONResponse(apply(store, config, name, submitted, user))
except ChangedElsewhere as exc:
return JSONResponse({"outcome": "rejected", "error": str(exc)}, status_code=409)
except (ValueError, LookupError) as exc:
return JSONResponse({"outcome": "rejected", "error": str(exc)}, status_code=400)
except Exception:
return JSONResponse({"outcome": "unknown", "error": "Save outcome unknown. Compare latest before retrying."}, status_code=503)

def policy_recovery(request, user, name, data, message, status):
try:
config = broker_config.load()
Expand Down
117 changes: 117 additions & 0 deletions admin/tests/settings_fixtures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""600-function production editor, entirely fake and offscreen."""
from admin.views.settings import settings_view


def state():
return dict(name="writer-demo", caller_id=1, operator="fake-admin", installation="fake-installation", revision="one", csrf="fake",
enabled=["tool00"], policy={"tools": {"tool00": {"fn01": "review"}}},
catalog={f"tool{t:02}": [{"op": f"fn{o:02}", "risk": "read", "description": "Fake function"}
for o in range(20)] for t in range(30)})


CHECKS = r"""
const initial=JSON.parse(document.getElementById('caller-settings-state').textContent);
let remote=JSON.parse(JSON.stringify(initial)),posts=0,mode='read';
window.fetch=async(url,options={})=>{
if(options.method==='POST'){posts++;if(mode==='unknown')throw Error('lost receipt');return {ok:false,status:409,json:async()=>({outcome:'rejected'})};}
return {ok:true,status:200,json:async()=>remote};
};
const dlg=()=>document.querySelector('.caller-settings'),get=(s)=>dlg().querySelector(s);
const named=(s)=>[...document.querySelectorAll('dialog[open] button')].find(n=>n.textContent===s);
const focus=(s)=>get('[data-focus="'+s+'"]');
const type=q=>{const s=get('input[type=search]');s.value=q;s.dispatchEvent(new Event('input',{bubbles:true}));};
check(!!dlg()&&dlg().getAttribute('aria-label')==='Caller settings for writer-demo','named modal popup');
check(document.activeElement.getAttribute('role')==='tab','focus enters selected tab');
check(get('[role=status]').textContent.includes('600'),'600 functions counted');
check(dlg().querySelectorAll('.settings-group').length===30,'30 collapsed tool groups');
check(dlg().querySelectorAll('.function-row').length===0,'collapsed groups do not create 600 controls');
focus('tool-tool01').click();focus('group-tool01').click();
check(dlg().querySelectorAll('.function-row input:checked').length===20,'whole tool enables all 20 functions');
focus('tab-policy').click();
const pick=focus('effect-tool01.fn01');pick.value='review';pick.dispatchEvent(new Event('change',{bubbles:true}));
check(document.activeElement===get('input[type=search]')||document.activeElement.dataset.focus==='tab-policy','tab switch retains keyboard focus');
type('fn01');check(dlg().querySelectorAll('.function-row').length===30,'search opens matching groups across all tools');
check(focus('effect-tool01.fn01').value==='review','policy edit survives search');
focus('tab-enabled').click();focus('bulk-tool00-false').focus();focus('bulk-tool00-false').click();
check(!focus('enable-tool00.fn01').checked,'None matches disables filtered function');
check(document.activeElement.dataset.focus==='bulk-tool00-false','bulk rerender retains focus');
type('fn02');check(focus('enable-tool00.fn02').checked,'None matches leaves hidden functions unchanged');
type('fn01');focus('tab-policy').click();check(focus('effect-tool01.fn01').value==='review','both tabs share one draft');
const selected=get('[role=tab][aria-selected=true]');selected.dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowRight',bubbles:true}));
check(get('[role=tab][aria-selected=true]').id==='settings-tab-enabled','Right arrow wraps tabs');
get('[role=tab][aria-selected=true]').dispatchEvent(new KeyboardEvent('keydown',{key:'End',bubbles:true}));
check(get('[role=tab][aria-selected=true]').id==='settings-tab-policy','End selects last tab');
type('no-such-function');check(dlg().textContent.includes('No matching functions'),'empty search recovery');
named('Clear filters').click();check(get('input[type=search]').value==='','clear filters restores groups');
check(document.activeElement===get('input[type=search]'),'empty recovery focuses search');
remote.revision='two';remote.policy.tools.tool00.fn02='allow';
named('Save changes').click();await wait(100);
check(posts===1&&named('Save changes').disabled,'stale rejection blocks Save without replay');
named('Compare latest').click();await wait(100);
let comparison=document.querySelector('.settings-compare');
check(!!comparison&&comparison.textContent.includes('Your draft')&&comparison.textContent.includes('Latest saved'),'side-by-side comparison');
check(document.activeElement.textContent==='Stay','comparison defaults to Stay');
named('Keep my edits on latest').click();
type('fn02');check(focus('effect-tool00.fn02').value==='allow','explicit rebase keeps remote unedited policy');
type('fn01');check(focus('effect-tool01.fn01').value==='review','explicit rebase retains local policy edit');
focus('tab-enabled').click();check(!focus('enable-tool00.fn01').checked,'explicit rebase retains function exclusion');
mode='unknown';named('Save changes').click();await wait(100);
check(posts===2&&named('Save changes').disabled&&dlg().textContent.includes('Save outcome unknown'),'lost receipt blocks replay');
named('Save changes').click();check(posts===2,'duplicate unknown save sends no request');
dlg().dispatchEvent(new Event('cancel',{cancelable:true}));
check(document.activeElement.textContent==='Stay','Escape dismissal defaults to Stay');
document.activeElement.click();check(!!dlg(),'Stay retains popup');
named('Cancel').click();named('Keep draft and close').click();await wait(100);
check(!dlg(),'explicit keep-draft close');
ToolstackSettings.open(remote);await wait(100);
check(dlg().querySelector('input[type=search]').value==='fn01','durable draft restores shared search on reopen');
check(named('Save changes').disabled&&dlg().textContent.includes('Save outcome unknown'),'durable unknown outcome cannot replay after reopen');
check(!focus('enable-tool00.fn01').checked,'durable draft restores enablement');
named('Compare latest').click();await wait(100);named('Stay').click();
check(posts===2,'comparison never sends a mutation');
named('Cancel').click();named('Discard draft').click();await wait(100);
ToolstackSettings.open(remote);await wait(100);
check(named('Save changes').disabled&&get('input[type=search]').value==='','discard explicitly starts clean');
check(localStorage.length===0,'draft storage cleared after discard');
type('fn01');focus('tab-policy').click();
"""


def settings_fixtures(instrument):
html = settings_view(user="fake-admin", csrf="fake", caller="writer-demo", state=state())
result = [dict(name=f"caller-settings-{width}", html=instrument(html, CHECKS), width=width, height=900)
for width in (1180, 620, 390)]
result.append(dict(name="caller-settings-save-1180", html=instrument(html, SAVE_CHECKS), width=1180, height=900))
return result


SAVE_CHECKS = r"""
const initial=JSON.parse(document.getElementById('caller-settings-state').textContent);
const d=()=>document.querySelector('.caller-settings');
const named=s=>[...d().querySelectorAll('button')].find(n=>n.textContent===s);
const control=s=>d().querySelector('[data-focus="'+s+'"]');
const search=d().querySelector('[type=search]');search.value='fn01';search.dispatchEvent(new Event('input'));
const filters=d().querySelectorAll('.settings-filter [type=checkbox]');filters[1].click();
check(d().querySelectorAll('.function-row').length===1,'Needs review shows only Review policies');
control('tab-policy').click();const effect=control('effect-tool00.fn01');effect.focus();effect.value='allow';effect.dispatchEvent(new Event('change'));
check(document.activeElement===search,'filter removing edited row returns focus to search');
check(d().textContent.includes('No matching functions'),'review filter responds to changed policy');
named('Clear filters').click();control('tab-enabled').click();control('tool-tool01').click();
let posts=0,ack;
window.fetch=async(url,options)=>{
check(options.method==='POST','single combined mutation uses POST');posts++;
const form=new URLSearchParams(options.body),body=JSON.parse(form.get('settings_json'));
check(form.get('_csrf')==='fake'&&body.revision==='one','combined Save carries CSRF and loaded revision');
check(body.enabled.includes('tool01')&&body.tools.tool00.fn01==='allow','combined Save includes both tab edits');
check(Object.keys(body.tools).length===30&&Object.values(body.tools).reduce((n,x)=>n+Object.keys(x).length,0)===600,'combined Save includes exactly the observed 600-function scope');
return new Promise(resolve=>{ack=()=>resolve({ok:true,status:200,json:async()=>({outcome:'saved',name:initial.name,policy:body})});});
};
named('Save changes').click();named('Save changes').click();
check(posts===1&&named('Save changes').disabled,'in-flight duplicate Save is latched');
check([...d().querySelectorAll('.settings-list button,input,select')].every(x=>x.disabled),'in-flight edits cannot change the submitted draft');
const entry=JSON.parse(localStorage.getItem(localStorage.key(0)));
check(entry.unknown===true,'interrupted in-flight write is durably blocked before dispatch');
ack();await wait(100);
check(!d()&&localStorage.length===0,'exact combined receipt closes editor and clears durable draft');
check(posts===1,'successful Save never replays');
"""
6 changes: 3 additions & 3 deletions admin/tests/test_caller_row_activation.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,17 @@ def test_rows_keep_named_keyboard_path_and_existing_destination(self):
tags = Tags(html).tags
row = next(attrs for tag, attrs in tags if tag == 'tr' and 'data-caller-policy' in attrs)
self.assertEqual(row['tabindex'], '0')
self.assertEqual(row['aria-label'], 'Open policy settings for writer-demo')
self.assertEqual(row['aria-label'], 'Open caller settings for writer-demo')
link = next(attrs for tag, attrs in tags if 'data-policy-link' in attrs)
self.assertEqual(link['href'], '/callers/writer-demo/policy')
self.assertEqual(link['href'], '/callers/writer-demo/settings')
self.assertEqual(sum(tag == 'button' for tag, _ in tags), 3)

def test_empty_and_escaped_names(self):
self.assertNotIn('data-caller-policy', _callers_section([], 'fake'))
html = _callers_section([dict(name="a'<>\"", revoked_at=None)], 'fake')
rows = [attrs for tag, attrs in Tags(html).tags if 'data-caller-policy' in attrs]
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]['aria-label'], "Open policy settings for a'<>\"")
self.assertEqual(rows[0]['aria-label'], "Open caller settings for a'<>\"")

def test_generated_script_guards_nested_actions_and_repeat(self):
html = page('Callers', _callers_section([], 'fake'), user=None)
Expand Down
Loading
Loading