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:
imSp4rky
2026-08-12 17:58:55 -06:00
parent 274b2fc308
commit 5796729bf3
8 changed files with 231 additions and 20 deletions
+55
View File
@@ -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