mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 20:15:10 +00:00
feat(remote): accept serve.users keys in api-only mode and add client auth-header fallback
api-only/remote-only serve now authenticates every serve.users key (str-coerced), not just api_secret, so per-user service allowlists apply. RemoteClient tries X-Secret-Key then X-Api-Key on 401 (sticky winner); remote_services.auth_headers prepends extra header names to the defaults.
This commit is contained in:
@@ -85,6 +85,65 @@ def test_4xx_raises_systemexit_with_logged_error(client: RemoteClient, caplog: p
|
||||
assert "no such session" in caplog.text
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_401_falls_back_to_next_auth_header(client: RemoteClient) -> None:
|
||||
seen: list[tuple[str | None, str | None]] = []
|
||||
|
||||
def cb(request):
|
||||
seen.append((request.headers.get("X-Secret-Key"), request.headers.get("X-Api-Key")))
|
||||
if request.headers.get("X-Api-Key") == "secret-xyz":
|
||||
return (200, {}, json.dumps({"status": "ok"}))
|
||||
return (401, {}, json.dumps({"message": "Secret Key is Invalid."}))
|
||||
|
||||
responses.add_callback(responses.GET, "http://srv:8786/api/services", callback=cb)
|
||||
|
||||
assert client.get("/api/services") == {"status": "ok"}
|
||||
assert seen == [("secret-xyz", None), (None, "secret-xyz")]
|
||||
|
||||
# the winning header is sticky: no second attempt on the next request
|
||||
seen.clear()
|
||||
assert client.get("/api/services") == {"status": "ok"}
|
||||
assert seen == [(None, "secret-xyz")]
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_non_401_error_does_not_fall_back(client: RemoteClient, caplog: pytest.LogCaptureFixture) -> None:
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"http://srv:8786/api/services",
|
||||
json={"error_code": "FORBIDDEN", "message": "nope"},
|
||||
status=403,
|
||||
)
|
||||
with caplog.at_level("ERROR"), pytest.raises(SystemExit):
|
||||
client.get("/api/services")
|
||||
assert len(responses.calls) == 1
|
||||
assert client.session.headers.get("X-Secret-Key") == "secret-xyz"
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_401_on_every_header_exits(client: RemoteClient, caplog: pytest.LogCaptureFixture) -> None:
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"http://srv:8786/api/services",
|
||||
json={"error_code": "UNAUTHORIZED", "message": "Secret Key is Invalid."},
|
||||
status=401,
|
||||
)
|
||||
with caplog.at_level("ERROR"), pytest.raises(SystemExit):
|
||||
client.get("/api/services")
|
||||
assert len(responses.calls) == 2
|
||||
assert "Secret Key is Invalid." in caplog.text
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_single_configured_auth_header_does_not_retry() -> None:
|
||||
c = RemoteClient(server_url="http://srv:8786", api_key="k", auth_headers=["X-Api-Key"])
|
||||
responses.add(responses.GET, "http://srv:8786/api/services", json={"message": "nope"}, status=401)
|
||||
with pytest.raises(SystemExit):
|
||||
c.get("/api/services")
|
||||
assert len(responses.calls) == 1
|
||||
assert c.session.headers.get("X-Api-Key") == "k"
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_connection_error_raises_systemexit(client: RemoteClient) -> None:
|
||||
import requests
|
||||
|
||||
@@ -49,6 +49,43 @@ def multi_remote_services(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_server_default_auth_headers(single_remote_service) -> None:
|
||||
_, _, services = resolve_server("primary")
|
||||
assert services["_auth_headers"] == ["X-Secret-Key", "X-Api-Key"]
|
||||
|
||||
|
||||
def test_resolve_server_custom_auth_headers_extend_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from unshackle.core import remote_service as rs
|
||||
|
||||
monkeypatch.setattr(
|
||||
rs.config, "remote_services", {"p": {"url": "https://p:8080", "auth_headers": ["Authorization"]}}
|
||||
)
|
||||
_, _, services = resolve_server("p")
|
||||
assert services["_auth_headers"] == ["Authorization", "X-Secret-Key", "X-Api-Key"]
|
||||
|
||||
|
||||
def test_resolve_server_auth_headers_dedup_is_case_insensitive(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from unshackle.core import remote_service as rs
|
||||
|
||||
monkeypatch.setattr(
|
||||
rs.config,
|
||||
"remote_services",
|
||||
{"p": {"url": "https://p:8080", "auth_headers": ["Authorization", "x-secret-key"]}},
|
||||
)
|
||||
_, _, services = resolve_server("p")
|
||||
assert services["_auth_headers"] == ["Authorization", "x-secret-key", "X-Api-Key"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["X-Api-Key", [], [""], ["X-Api-Key", 3]])
|
||||
def test_resolve_server_bad_auth_headers_raises(monkeypatch: pytest.MonkeyPatch, bad: object) -> None:
|
||||
from unshackle.core import remote_service as rs
|
||||
|
||||
monkeypatch.setattr(rs.config, "remote_services", {"p": {"url": "https://p:8080", "auth_headers": bad}})
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
resolve_server("p")
|
||||
assert "auth_headers" in str(exc.value.message)
|
||||
|
||||
|
||||
def test_resolve_server_no_config_raises_click(empty_remote_services) -> None:
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
resolve_server(None)
|
||||
|
||||
@@ -69,6 +69,61 @@ def test_serve_no_key_without_api_secret_does_not_require_secret(
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
|
||||
async def test_api_only_accepts_serve_users_keys(
|
||||
runner: CliRunner, monkeypatch: pytest.MonkeyPatch, aiohttp_client
|
||||
) -> None:
|
||||
"""In api-only keyed mode the accepted keys are api_secret plus every `serve.users` key."""
|
||||
from aiohttp import web
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def capture_app(app, **kwargs):
|
||||
captured["app"] = app
|
||||
|
||||
monkeypatch.setattr(web, "run_app", capture_app)
|
||||
from unshackle.core.config import config as cfg
|
||||
|
||||
monkeypatch.setattr(
|
||||
cfg,
|
||||
"serve",
|
||||
{"api_secret": "master-key", "users": {"user-key": {"username": "alice", "services": ["EXAMPLE"]}}},
|
||||
)
|
||||
|
||||
result = runner.invoke(serve, ["--api-only", "--remote-only"])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
app = captured["app"]
|
||||
assert set(app["config"]["users"]) == {"master-key", "user-key"}
|
||||
assert app["config"]["users"]["user-key"]["username"] == "alice"
|
||||
|
||||
client = await aiohttp_client(app)
|
||||
# an unrouted path still passes through the auth middleware first
|
||||
assert (await client.get("/api/unknown", headers={"X-Secret-Key": "user-key"})).status == 404
|
||||
assert (await client.get("/api/unknown", headers={"X-Secret-Key": "master-key"})).status == 404
|
||||
assert (await client.get("/api/unknown", headers={"X-Secret-Key": "nope"})).status == 401
|
||||
|
||||
|
||||
async def test_api_only_coerces_non_str_user_keys(
|
||||
runner: CliRunner, monkeypatch: pytest.MonkeyPatch, aiohttp_client
|
||||
) -> None:
|
||||
"""A yaml users key that parses as int must still authenticate (compare_digest needs str)."""
|
||||
from aiohttp import web
|
||||
|
||||
captured: dict = {}
|
||||
monkeypatch.setattr(web, "run_app", lambda app, **kwargs: captured.update(app=app))
|
||||
from unshackle.core.config import config as cfg
|
||||
|
||||
monkeypatch.setattr(cfg, "serve", {"api_secret": "master-key", "users": {123456: {"username": "bob"}}})
|
||||
|
||||
result = runner.invoke(serve, ["--api-only", "--remote-only"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "123456" in captured["app"]["config"]["users"]
|
||||
|
||||
client = await aiohttp_client(captured["app"])
|
||||
assert (await client.get("/api/unknown", headers={"X-Secret-Key": "123456"})).status == 404
|
||||
assert (await client.get("/api/unknown", headers={"X-Secret-Key": "master-key"})).status == 404
|
||||
|
||||
|
||||
def test_serve_without_no_key_requires_api_secret(runner: CliRunner, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from unshackle.core.config import config as cfg
|
||||
|
||||
|
||||
Reference in New Issue
Block a user