diff --git a/admin/api.py b/admin/api.py index b1f3059..e0e2585 100644 --- a/admin/api.py +++ b/admin/api.py @@ -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: diff --git a/admin/caller_settings.py b/admin/caller_settings.py new file mode 100644 index 0000000..d71d3f0 --- /dev/null +++ b/admin/caller_settings.py @@ -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} diff --git a/admin/server.py b/admin/server.py index 38d74ce..6108767 100644 --- a/admin/server.py +++ b/admin/server.py @@ -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() diff --git a/admin/tests/settings_fixtures.py b/admin/tests/settings_fixtures.py new file mode 100644 index 0000000..1c8469c --- /dev/null +++ b/admin/tests/settings_fixtures.py @@ -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'); +""" diff --git a/admin/tests/test_caller_row_activation.py b/admin/tests/test_caller_row_activation.py index 452b895..2b1340e 100644 --- a/admin/tests/test_caller_row_activation.py +++ b/admin/tests/test_caller_row_activation.py @@ -23,9 +23,9 @@ 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): @@ -33,7 +33,7 @@ def test_empty_and_escaped_names(self): 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) diff --git a/admin/tests/test_caller_settings.py b/admin/tests/test_caller_settings.py new file mode 100644 index 0000000..4461cb6 --- /dev/null +++ b/admin/tests/test_caller_settings.py @@ -0,0 +1,181 @@ +"""Atomic combined settings and revision guards with real SQLite connections.""" +import copy +import json +import sqlite3 +import time +import unittest +from contextlib import closing +from pathlib import Path + +from admin.tests import test_api +from broker import operations, policy +from broker.store import Store + + +class CallerSettingsTests(unittest.TestCase): + setUp = test_api.JsonApi.setUp + _restore_env = test_api.JsonApi._restore_env + _auth = test_api.JsonApi._auth + _token = test_api.JsonApi._token + + def seed(self, before=None): + self.headers = self._auth() + with closing(Store(self.db_path)) as store: + caller = store.add_caller("writer-demo") + store.set_policy(caller, before or {"tools": {"echo": {"say": "review"}}}) + self.url = "/api/callers/writer-demo/settings" + state = self.client.get(self.url, headers=self.headers) + self.assertEqual(state.status_code, 200, state.text) + return state.json() + + def edit(self, state): + return {"revision": state["revision"], "enabled": [t for t in state["enabled"] if t in state["catalog"]], + "disabled_functions": {t: [] for t in state["catalog"]}, + "tools": {t: {o["op"]: state["policy"].get("tools", {}).get(t, {}).get(o["op"], "deny") + for o in ops} for t, ops in state["catalog"].items()}} + + def test_one_save_gates_functions_and_retains_disabled_policy(self): + state = self.seed() + draft = self.edit(state) + draft["tools"]["echo"]["say"] = "allow" + draft["disabled_functions"]["echo"] = ["say"] + response = self.client.put(self.url, headers=self.headers, json=draft) + self.assertEqual(response.status_code, 200, response.text) + after = response.json()["policy"] + self.assertEqual(after["tools"]["echo"]["say"], "allow") + self.assertEqual(policy.decide(after, "echo", "say"), "deny") + state = self.client.get(self.url, headers=self.headers).json() + draft = self.edit(state) + response = self.client.put(self.url, headers=self.headers, json=draft) + self.assertEqual(policy.decide(response.json()["policy"], "echo", "say"), "allow") + state = self.client.get(self.url, headers=self.headers).json() + draft = self.edit(state); draft["enabled"] = [] + after = self.client.put(self.url, headers=self.headers, json=draft).json()["policy"] + self.assertEqual(policy.decide(after, "echo", "say"), "deny") + self.assertEqual(after["tools"]["echo"]["say"], "allow") + self.assertEqual(operations.enabled_tools(after), []) + + def test_stale_write_has_no_effect_or_audit(self): + state = self.seed(); draft = self.edit(state) + with closing(Store(self.db_path)) as store: + operations.set_policy(store, "writer-demo", ["echo.say"], [], "other") + count = len(store.recent_audit(500)) + response = self.client.put(self.url, headers=self.headers, json=draft) + self.assertEqual(response.status_code, 409) + with closing(Store(self.db_path)) as store: + self.assertEqual(len(store.recent_audit(500)), count) + self.assertEqual(store.policy_for(store.caller_by_name("writer-demo")["id"])["tools"]["echo"]["say"], "allow") + + def test_preserves_unseen_tools_operations_and_future_metadata(self): + state = self.seed({"enabled": ["echo", "offline"], "tools": {"echo": {"say": "review", "retired": "allow"}, "offline": {"op": "review"}}, + "disabled_functions": {"echo": ["retired"], "offline": ["gone"]}, "future": {"keep": True}}) + draft = self.edit(state); draft["tools"]["echo"]["say"] = "deny" + after = self.client.put(self.url, headers=self.headers, json=draft).json()["policy"] + self.assertEqual(after["enabled"], ["echo", "offline"]) + self.assertEqual(after["tools"]["echo"]["retired"], "allow") + self.assertEqual(after["tools"]["offline"], {"op": "review"}) + self.assertEqual(after["disabled_functions"], {"echo": ["retired"], "offline": ["gone"]}) + self.assertEqual(after["future"], {"keep": True}) + + def test_invalid_payload_never_partially_writes(self): + state = self.seed(); good = self.edit(state) + for field, value in (("enabled", ["unknown"]), ("enabled", "echo"), ("tools", {"echo": {"say": "permit"}}), + ("tools", {}), ("disabled_functions", {"echo": ["gone"]}), ("disabled_functions", [])): + draft = copy.deepcopy(good); draft[field] = value + response = self.client.put(self.url, headers=self.headers, json=draft) + self.assertIn(response.status_code, (400, 409), response.text) + self.assertEqual(self.client.get(self.url, headers=self.headers).json()["revision"], state["revision"]) + + def test_catalog_change_blocks_old_draft(self): + state = self.seed(); draft = self.edit(state) + path = Path(self.tmp, "tools/echo/toolyard.toml") + path.write_text(path.read_text() + '\n[[operations]]\nname="new_op"\nrisk="read"\ndescription="new"\n') + self.assertEqual(self.client.put(self.url, headers=self.headers, json=draft).status_code, 409) + + def test_revoked_caller_cannot_save(self): + state = self.seed(); draft = self.edit(state) + with closing(Store(self.db_path)) as store: store.revoke_caller("writer-demo") + self.assertEqual(self.client.put(self.url, headers=self.headers, json=draft).status_code, 404) + + def test_database_compare_and_swap_closes_the_read_write_race(self): + self.seed() + with closing(Store(self.db_path)) as first, closing(Store(self.db_path)) as second: + caller = first.caller_by_name("writer-demo")["id"] + before = first.policy_for(caller) + changed = {"enabled": [], "tools": before["tools"]} + self.assertTrue(first.compare_and_set_policy(caller, before, changed, "one")) + self.assertFalse(second.compare_and_set_policy(caller, before, {"tools": {}}, "two")) + self.assertEqual(second.policy_for(caller), changed) + + def test_audit_failure_rolls_back_policy_and_invalidation(self): + self.seed() + with closing(Store(self.db_path)) as store: + caller = store.caller_by_name("writer-demo")["id"]; before = store.policy_for(caller) + request = store.create_request("fake-approval", caller, "echo", "say", "pending_approval") + store.create_approval(request, "fake-card", time.time() + 300) + store._conn.execute("CREATE TRIGGER fail_settings BEFORE INSERT ON audit_events BEGIN SELECT RAISE(ABORT, 'fake full disk'); END") + store._conn.commit() + with self.assertRaises(sqlite3.IntegrityError): + store.compare_and_set_policy(caller, before, {"enabled": [], "tools": {}}, "operator") + self.assertEqual(store.policy_for(caller), before) + self.assertEqual(store.request(request)["status"], "pending_approval") + self.assertEqual(store.approval_for_request(request)["status"], "pending") + + def test_combined_save_invalidates_pending_approval_in_the_same_commit(self): + state = self.seed(); draft = self.edit(state) + with closing(Store(self.db_path)) as store: + caller = store.caller_by_name("writer-demo")["id"] + request = store.create_request("fake-approval", caller, "echo", "say", "pending_approval") + store.create_approval(request, "fake-card", time.time() + 300) + draft["tools"]["echo"]["say"] = "allow" + self.assertEqual(self.client.put(self.url, headers=self.headers, json=draft).status_code, 200) + with closing(Store(self.db_path)) as store: + self.assertEqual(store.policy_for(caller)["tools"]["echo"]["say"], "allow") + self.assertEqual(store.request(request)["status"], "denied") + self.assertEqual(store.approval_for_request(request)["status"], "cancelled") + self.assertEqual(len(store.recent_audit(500)), 1) + + def test_legacy_mutations_keep_function_exclusions(self): + self.seed({"enabled": ["echo"], "tools": {"echo": {"say": "allow"}}, "disabled_functions": {"echo": ["say"]}}) + with closing(Store(self.db_path)) as store: + operations.set_policy(store, "writer-demo", ["echo.say"], [], "operator") + caller = store.caller_by_name("writer-demo")["id"] + self.assertEqual(policy.decide(store.policy_for(caller), "echo", "say"), "deny") + operations.set_enabled_tools(store, "writer-demo", [], "operator") + self.assertEqual(store.policy_for(caller)["enabled"], []) + + def test_web_settings_uses_cookie_csrf_not_bearer_authority(self): + self.seed() + url = "/callers/writer-demo/settings" + self.assertEqual(self.client.get(url, headers=self.headers, follow_redirects=False).status_code, 303) + self.assertEqual(self.client.post(url, headers=self.headers, data={}).status_code, 401) + self.client.post("/login", data={"username": "admin", "password": test_api.PASSWORD}) + state = self.client.get(url, headers={"Accept": "application/json"}).json() + draft = self.edit(state) + self.assertNotIn("csrf", self.client.get(self.url, headers=self.headers).json()) + self.assertEqual(self.client.post(url, data={"settings_json": json.dumps(draft)}).status_code, 403) + response = self.client.post(url, data={"_csrf": state["csrf"], "settings_json": json.dumps(draft)}) + self.assertEqual(response.status_code, 200, response.text) + + def test_30_tools_by_20_functions_commit_together(self): + for index in range(30): + tool = "echo" if index == 0 else f"tool{index:02}" + directory = Path(self.tmp, "tools", tool); directory.mkdir(exist_ok=True) + content = f'id="{tool}"\ntype="api"\n[entrypoint]\ncommand="never-run"\nport={4601 + index}\n' + content += "".join(f'\n[[operations]]\nname="fn{op:02}"\nrisk="read"\ndescription="Fake function"\n' for op in range(20)) + (directory / "toolyard.toml").write_text(content) + state = self.seed(); draft = self.edit(state) + self.assertEqual(len(state["catalog"]), 30) + self.assertEqual(sum(map(len, state["catalog"].values())), 600) + draft["enabled"] = list(state["catalog"]) + for tool in draft["tools"]: + draft["tools"][tool]["fn01"] = "review" + draft["disabled_functions"][tool] = ["fn02"] + result = self.client.put(self.url, headers=self.headers, json=draft) + self.assertEqual(result.status_code, 200, result.text) + after = result.json()["policy"] + for tool in state["catalog"]: + self.assertEqual(policy.decide(after, tool, "fn01"), "review") + self.assertEqual(policy.decide(after, tool, "fn02"), "deny") + with closing(Store(self.db_path)) as store: + self.assertEqual([event["event_type"] for event in store.recent_audit(500)], ["settings_changed"]) diff --git a/admin/tests/webkit_fixtures.py b/admin/tests/webkit_fixtures.py index f1c0e35..3261b09 100644 --- a/admin/tests/webkit_fixtures.py +++ b/admin/tests/webkit_fixtures.py @@ -32,18 +32,18 @@ const rows=document.querySelectorAll('[data-caller-policy]'), row=rows[0]; const doubleClick=el=>el.dispatchEvent(new MouseEvent('dblclick',{bubbles:true})); doubleClick(row.cells[0]);await wait(150); -check(window.fixtureNavigations.join() === 'https://fixture.invalid/callers/writer-demo/policy','row double-click navigates to caller policy'); +check(window.fixtureNavigations.join() === 'https://fixture.invalid/callers/writer-demo/settings','row double-click opens unified caller settings'); window.fixtureNavigations=[]; for(const el of row.querySelectorAll('button,a,input,form'))doubleClick(el); await wait(150);check(window.fixtureNavigations.length===0,'nested row controls never trigger double-click navigation'); row.focus();check(document.activeElement===row && !!row.getAttribute('aria-label'),'caller row focusable and named'); row.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true,cancelable:true}));await wait(150); -check(window.fixtureNavigations.join() === 'https://fixture.invalid/callers/writer-demo/policy','Enter on focused row navigates to caller policy'); +check(window.fixtureNavigations.join() === 'https://fixture.invalid/callers/writer-demo/settings','Enter on focused row opens unified caller settings'); window.fixtureNavigations=[]; row.querySelector('button').dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true})); await wait();check(window.fixtureNavigations.length===0,'nested keyboard action does not navigate'); doubleClick(rows[1].cells[1]);await wait(150); -check(window.fixtureNavigations.join() === 'https://fixture.invalid/callers/revoked-demo/policy','revoked row retains existing policy destination'); +check(window.fixtureNavigations.join() === 'https://fixture.invalid/callers/revoked-demo/settings','revoked row reaches guarded settings destination'); """ CONFIRM = r""" @@ -102,6 +102,7 @@ def instrument(html, checks, returned=None): const results=[];const check=(ok,name)=>results.push({name,ok:!!ok}); const wait=(ms=40)=>new Promise(r=>setTimeout(r,ms)); const checkDialogButtons=dialog=>{ + if(dialog.matches('.caller-settings,.settings-compare'))return; const buttons=Array.from(dialog.querySelectorAll('button')); check(buttons.length===2,'open dialog has two actions'); const bounds=buttons.map(button=>{ @@ -234,7 +235,8 @@ def fixtures(): for width in (1440,1180): result.append(dict(name=f'{name}-{width}',html=instrument(html,checks,returned),width=width,height=900)) result.append(dict(name='shell-startup-620',html=instrument(startup_error('http://127.0.0.1:8780','Admin exited with status 7 before /login responded.'),"check(document.body.textContent.includes('status 7'),'shell observed cause');check(document.querySelector('#retry')!==null,'shell Retry available');let retries=0;window.pywebview={api:{retry:()=>{retries++;}}};document.querySelector('#retry').click();document.querySelector('#retry').click();check(retries===1,'shell Retry button latches synthetic duplicate clicks');"),width=620,height=360)) - return result + from admin.tests.settings_fixtures import settings_fixtures + return result + settings_fixtures(instrument) if __name__ == '__main__': diff --git a/admin/views/dashboard.py b/admin/views/dashboard.py index c122062..ad9375d 100644 --- a/admin/views/dashboard.py +++ b/admin/views/dashboard.py @@ -64,13 +64,12 @@ def token_reveal_banner(lead: str, token: str) -> str: def _callers_section(callers, csrf: str) -> str: rows = "".join( - f"