feat(auth): add interactive key policy editing
- Add auth policy to update existing authorized_keys allow policies locally or over remote serve. - Support key lookup by public key or exact name, with safe, all, server-default, and category-based modes. - Add questionary-powered interactive key selection and checkbox policy editing with current policy preselected. - Show policy descriptions in auth keys output so each capability is easier to understand. - Gate the new remote control command behind the existing keys policy category and include protocol routing/compat updates. - Bump real-browser-cli to 0.16.2 and lock the new questionary dependency. - Cover local, remote, validation, and policy-category behavior in tests.
This commit is contained in:
@@ -237,9 +237,77 @@ def test_auth_keys_local_shows_policy_column(tmp_path):
|
||||
result = CliRunner().invoke(main, ["auth", "keys", "--file", str(keys)])
|
||||
assert result.exit_code == 0
|
||||
assert "Policy" in result.output
|
||||
assert "Description" in result.output
|
||||
assert "read-page" in result.output
|
||||
assert "all" in result.output
|
||||
assert "server default" in result.output
|
||||
assert "read page content" in result.output
|
||||
assert "Full access" in result.output
|
||||
|
||||
def test_auth_policy_updates_existing_key_policy(tmp_path):
|
||||
keys = tmp_path / "authorized_keys"
|
||||
pub = "a" * 64
|
||||
keys.write_text(f"{pub} YubiKey 5C NFC FIPS\n")
|
||||
result = CliRunner().invoke(main, [
|
||||
"auth", "policy", pub, "--file", str(keys), "--allow-read-page", "--allow-control",
|
||||
])
|
||||
assert result.exit_code == 0
|
||||
assert keys.read_text().strip() == f"{pub} YubiKey 5C NFC FIPS allow:read-page,control"
|
||||
assert "Updated policy" in result.output
|
||||
|
||||
def test_auth_policy_can_set_safe_and_server_default_by_name(tmp_path):
|
||||
keys = tmp_path / "authorized_keys"
|
||||
pub = "b" * 64
|
||||
keys.write_text(f"{pub} laptop allow:all\n")
|
||||
|
||||
safe_result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys), "--safe"])
|
||||
assert safe_result.exit_code == 0
|
||||
assert keys.read_text().strip() == f"{pub} laptop allow:"
|
||||
|
||||
default_result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys), "--server-default"])
|
||||
assert default_result.exit_code == 0
|
||||
assert keys.read_text().strip() == f"{pub} laptop"
|
||||
|
||||
def test_auth_policy_requires_policy_mode_when_not_interactive(tmp_path):
|
||||
keys = tmp_path / "authorized_keys"
|
||||
keys.write_text(f"{'a' * 64} laptop\n")
|
||||
result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys)])
|
||||
assert result.exit_code == 1
|
||||
assert "Choose a policy mode" in result.output
|
||||
|
||||
def test_auth_policy_rejects_conflicting_policy_modes(tmp_path):
|
||||
keys = tmp_path / "authorized_keys"
|
||||
keys.write_text(f"{'a' * 64} laptop\n")
|
||||
result = CliRunner().invoke(main, ["auth", "policy", "laptop", "--file", str(keys), "--safe", "--allow-all"])
|
||||
assert result.exit_code == 1
|
||||
assert "Choose exactly one policy mode" in result.output
|
||||
|
||||
def test_parse_interactive_policy_selection():
|
||||
from browser_cli.commands.auth import _parse_checkbox_policy_selection, _parse_policy_selection
|
||||
|
||||
assert _parse_policy_selection("1,2") == ["read-page", "control"]
|
||||
assert _parse_policy_selection("read-page control") == ["read-page", "control"]
|
||||
assert _parse_policy_selection("all") == ["all"]
|
||||
assert _parse_policy_selection("safe") == []
|
||||
assert _parse_policy_selection("default") is None
|
||||
assert _parse_checkbox_policy_selection(["read-page", "control"]) == ["read-page", "control"]
|
||||
assert _parse_checkbox_policy_selection(["__all__"]) == ["all"]
|
||||
assert _parse_checkbox_policy_selection(["__safe__"]) == []
|
||||
assert _parse_checkbox_policy_selection(["__server_default__"]) is None
|
||||
|
||||
def test_auth_policy_without_identifier_requires_interactive_picker():
|
||||
result = CliRunner().invoke(main, ["auth", "policy", "--allow-all"])
|
||||
assert result.exit_code == 1
|
||||
assert "Missing key identifier" in result.output
|
||||
|
||||
def test_auth_policy_remote_sends_policy_command():
|
||||
pub = "c" * 64
|
||||
with patch("browser_cli.client.send_command", return_value={"pubkey": pub, "name": "remote key", "allow": ["all"]}) as send:
|
||||
result = CliRunner().invoke(main, ["--remote", "browser-host.example:8765", "auth", "policy", pub, "--allow-all"])
|
||||
assert result.exit_code == 0
|
||||
send.assert_called_once()
|
||||
assert send.call_args.kwargs["args"] == {"identifier": pub, "allow": ["all"]}
|
||||
assert "Updated policy" in result.output
|
||||
|
||||
def test_auth_keys_remote_unreachable_clean_error():
|
||||
"""`auth keys --remote` on an unreachable host shows a clean error, not a traceback."""
|
||||
|
||||
@@ -42,10 +42,11 @@ def test_key_commands_are_keys_category():
|
||||
from browser_cli.command_security import command_category
|
||||
assert command_category("browser-cli.auth.keys") == "keys"
|
||||
assert command_category("browser-cli.auth.trust") == "keys"
|
||||
assert command_category("browser-cli.auth.policy") == "keys"
|
||||
assert command_category("browser-cli.targets") == "safe" # discovery stays open
|
||||
|
||||
def test_key_commands_blocked_without_allow_keys():
|
||||
for cmd in ("browser-cli.auth.keys", "browser-cli.auth.trust"):
|
||||
for cmd in ("browser-cli.auth.keys", "browser-cli.auth.trust", "browser-cli.auth.policy"):
|
||||
with pytest.raises(PermissionError):
|
||||
assert_command_allowed(cmd, CommandPolicy()) # safe-only default
|
||||
assert_command_allowed(cmd, CommandPolicy(allow_keys=True)) # explicit grant
|
||||
|
||||
Reference in New Issue
Block a user