From 0b64bcd4bedc77528555cf815270e177948896f8 Mon Sep 17 00:00:00 2001 From: Chris Geyer Date: Wed, 27 May 2026 15:22:45 +0000 Subject: [PATCH] fix(scope): publish/label with the active repo scope's visibility load_publish_auth_context built the current_user (and project) scope_request with a hardcoded visibility="private", ignoring `roar scope use`. So with the active scope set to `public` (public attributed), register and label sync still requested a private scope and the server fell back to the anonymous public bucket, instead of the user's own public-attributed scope. Resolve scope_request visibility from the active RepoScope mode: public -> "public" (attributed) anonymous -> omit scope_request (server uses the anonymous public scope) private / project / unset -> "private" (unchanged) So `roar scope use public` + register/label sync now lands in the authenticated user's public-attributed scope (anonymous-readable but owned by them). Pairs with server-side owner_resolution=current_user support (treqs-inc/glaas-api#47). Note: the `roar register/put --public` flag still routes to the anonymous bucket; flipping it to attributed changes auth requirements and is a focused follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) --- roar/publish_auth.py | 32 ++++++++++++++++++++----- tests/unit/test_publish_auth_context.py | 32 +++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/roar/publish_auth.py b/roar/publish_auth.py index f95629d8..831db165 100644 --- a/roar/publish_auth.py +++ b/roar/publish_auth.py @@ -31,6 +31,21 @@ class PublishAuthContext: ssh_auth_available: bool = False +def _scope_visibility(repo_scope: Any) -> str | None: + """Resolve the visibility for a scope_request from the active repo scope. + + ``public`` -> ``"public"`` (attributed); ``anonymous`` -> ``None`` so the + caller omits ``scope_request`` and the server uses the legacy anonymous + public scope; everything else (``private``/``project``/unset) -> ``"private"``. + """ + if repo_scope is not None: + if repo_scope.mode == "public": + return "public" + if repo_scope.mode == "anonymous": + return None + return "private" + + def load_publish_auth_context( start_dir: str | Path | None = None, *, @@ -95,17 +110,22 @@ def load_publish_auth_context( scope_request = { "owner_id": binding["owner_id"], "owner_type": binding["owner_type"], - "visibility": "private", + "visibility": _scope_visibility(repo_scope) or "private", } project_id = binding.get("project_id") if project_id: scope_request["project_id"] = project_id elif not allow_public_without_binding: - scope_request = { - "owner_resolution": "current_user", - "owner_type": "user", - "visibility": "private", - } + # Server-authoritative owner resolution; visibility follows the active + # repo scope. The anonymous scope yields None here so we omit + # scope_request entirely (server uses the legacy anonymous public scope). + visibility = _scope_visibility(repo_scope) + if visibility is not None: + scope_request = { + "owner_resolution": "current_user", + "owner_type": "user", + "visibility": visibility, + } return PublishAuthContext( access_token=access_token, diff --git a/tests/unit/test_publish_auth_context.py b/tests/unit/test_publish_auth_context.py index 235dad11..417067d3 100644 --- a/tests/unit/test_publish_auth_context.py +++ b/tests/unit/test_publish_auth_context.py @@ -120,6 +120,38 @@ def test_private_publish_without_binding_uses_current_user_scope_request( } +def test_public_scope_uses_current_user_public_scope_request(tmp_path: Path) -> None: + config_dir = tmp_path / ".roar" + config_dir.mkdir(parents=True) + (config_dir / "config.toml").write_text('[scope]\nmode = "public"\n', encoding="utf-8") + + with ( + patch("roar.publish_auth.load_auth_state", return_value=_auth_state()), + patch("roar.publish_auth._has_ssh_auth_credentials", return_value=False), + ): + context = load_publish_auth_context(start_dir=tmp_path, allow_public_without_binding=False) + + assert context.scope_request == { + "owner_resolution": "current_user", + "owner_type": "user", + "visibility": "public", + } + + +def test_anonymous_scope_omits_scope_request(tmp_path: Path) -> None: + config_dir = tmp_path / ".roar" + config_dir.mkdir(parents=True) + (config_dir / "config.toml").write_text('[scope]\nmode = "anonymous"\n', encoding="utf-8") + + with ( + patch("roar.publish_auth.load_auth_state", return_value=_auth_state()), + patch("roar.publish_auth._has_ssh_auth_credentials", return_value=False), + ): + context = load_publish_auth_context(start_dir=tmp_path, allow_public_without_binding=False) + + assert context.scope_request is None + + def test_project_bound_private_publish_keeps_project_scope_request(tmp_path: Path) -> None: config_dir = tmp_path / ".roar" config_dir.mkdir(parents=True)