Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions roar/application/publish/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,16 +192,20 @@ def build_register_preview_runtime(
from ...publish_auth import PublishAuthContext
from .lineage import LineageCollector

glaas_client = GlaasClient(
"",
start_dir=start_dir,
publish_auth=PublishAuthContext(
publish_auth = None
if not allow_public_without_binding:
publish_auth = PublishAuthContext(
access_token=None,
scope_request=None,
auth_provider=None,
user_sub=None,
db_user_id=None,
),
)

glaas_client = GlaasClient(
None,
start_dir=start_dir,
publish_auth=publish_auth,
allow_public_without_binding=allow_public_without_binding,
)
return _RegisterPreviewRuntime(
Expand Down
63 changes: 62 additions & 1 deletion roar/publish_auth.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
from __future__ import annotations

import json
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any

try:
import tomllib
Expand All @@ -18,6 +22,7 @@ class PublishAuthContext:
auth_provider: str | None = None
user_sub: str | None = None
db_user_id: str | None = None
creator_identity: str | None = None


def load_publish_auth_context(
Expand All @@ -36,7 +41,7 @@ def load_publish_auth_context(
user_sub = auth_state.user.sub or None
db_user_id = auth_state.user.db_user_id

binding = _load_repo_binding(start_dir)
binding = None if allow_public_without_binding else _load_repo_binding(start_dir)
if binding and not access_token:
raise RuntimeError(
"Repo is linked to GLaaS but no global auth state is available. Run `roar login`."
Expand All @@ -46,6 +51,12 @@ def load_publish_auth_context(
"No GLaaS repo binding found for this publish. Link the repo to a TReqs owner/project first, or rerun with --public to publish publicly."
)

creator_identity = None
if not access_token and allow_public_without_binding:
creator_identity, resolved_db_user_id = _load_authenticated_creator_identity()
if resolved_db_user_id and not db_user_id:
db_user_id = resolved_db_user_id

scope_request = None
if binding:
scope_request = {
Expand All @@ -63,10 +74,15 @@ def load_publish_auth_context(
auth_provider=auth_provider,
user_sub=user_sub,
db_user_id=db_user_id,
creator_identity=creator_identity,
)


def resolve_publish_creator_identity(context: PublishAuthContext) -> str:
explicit_identity = _optional_string(context.creator_identity)
if explicit_identity is not None:
return explicit_identity

provider = (context.auth_provider or "").strip().lower()
if provider.startswith("treqs") and context.user_sub:
return f"treqs:user:{context.user_sub}"
Expand All @@ -75,6 +91,51 @@ def resolve_publish_creator_identity(context: PublishAuthContext) -> str:
return "anonymous"


def _load_authenticated_creator_identity() -> tuple[str | None, str | None]:
from .integrations.glaas import get_glaas_url, make_auth_header

base_url = _optional_string(get_glaas_url())
if base_url is None:
return None, None

path = "/api/v1/auth/me"
auth_header = make_auth_header("GET", path, None)
if not auth_header:
return None, None

request = urllib.request.Request(f"{base_url.rstrip('/')}{path}")
request.add_header("Authorization", auth_header)
request.add_header("Accept", "application/json")

try:
with urllib.request.urlopen(request, timeout=10) as response:
payload = json.loads(response.read().decode("utf-8") or "{}")
except (urllib.error.HTTPError, urllib.error.URLError, json.JSONDecodeError):
return None, None

data = payload.get("data") if isinstance(payload, dict) else None
if not isinstance(data, dict):
return None, None

creator_identity = _optional_string(
data.get("creatorIdentity") if isinstance(data, dict) else None
) or _optional_string(data.get("creator_identity") if isinstance(data, dict) else None)

user = data.get("user")
db_user_id = _optional_string(user.get("id")) if isinstance(user, dict) else None
if creator_identity is None and db_user_id is not None:
creator_identity = f"glaas:user:{db_user_id}"

return creator_identity, db_user_id


def _optional_string(value: Any) -> str | None:
if value is None:
return None
normalized = str(value).strip()
return normalized or None


def _load_repo_binding(start_dir: str | Path | None = None) -> dict[str, str] | None:
config_path = _find_repo_config(start_dir)
if config_path is None or not config_path.exists():
Expand Down
54 changes: 51 additions & 3 deletions tests/integration/fake_glaas.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,23 @@ def _write_json(self, status_code: int, payload: dict[str, Any]) -> None:
self.end_headers()
self.wfile.write(body)

def _resolve_authenticated_user(self, authorization: str | None) -> dict[str, str] | None:
if authorization and authorization.startswith("Bearer "):
return {
"id": "user-123",
"email": "trevor@example.com",
"username": "trevor",
"auth_mode": "bearer",
}
if authorization and authorization.startswith("Signature "):
return {
"id": "ssh-user-123",
"email": "ssh-user@example.com",
"username": "ssh-user",
"auth_mode": "ssh",
}
return None

def do_GET(self) -> None:
authorization = self.headers.get("Authorization")
if self.path == "/api/v1/auth/access-context":
Expand Down Expand Up @@ -98,6 +115,30 @@ def do_GET(self) -> None:
)
return

if self.path == "/api/v1/auth/me":
self.server.auth_headers.append({"path": self.path, "authorization": authorization})
user = self._resolve_authenticated_user(authorization)
if user is None:
self._write_json(401, {"error": "Missing or invalid auth"})
return
self._write_json(
200,
{
"success": True,
"data": {
"user": {
"id": user["id"],
"email": user["email"],
"githubUsername": user["username"],
"treqsUserId": None,
},
"creatorIdentity": f"glaas:user:{user['id']}",
},
"meta": {"authMode": user["auth_mode"]},
},
)
return

if self.path == "/api/v1/health":
self.server.health_checks += 1
self._write_json(200, {"success": True, "status": "healthy"})
Expand Down Expand Up @@ -129,12 +170,19 @@ def do_POST(self) -> None:
payload = self._read_json()
authorization = self.headers.get("Authorization")
self.server.auth_headers.append({"path": self.path, "authorization": authorization})
if not authorization or not authorization.startswith("Bearer "):
self._write_json(401, {"error": "Missing or invalid bearer auth"})
authenticated_user = self._resolve_authenticated_user(authorization)
if authenticated_user is None:
self._write_json(401, {"error": "Missing or invalid auth"})
return

if self.path == "/api/v1/sessions":
self.server.session_registrations.append(payload)
self.server.session_registrations.append(
{
**payload,
"_authenticated_user_id": authenticated_user["id"],
"_auth_mode": authenticated_user["auth_mode"],
}
)
session_hash = str(payload.get("hash", ""))
self._write_json(
200,
Expand Down
138 changes: 138 additions & 0 deletions tests/integration/test_public_publish_intent_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from __future__ import annotations

import json
import re
import shutil
import subprocess
from pathlib import Path

Expand All @@ -19,6 +21,20 @@ def fake_glaas_publish_server() -> FakeGlaasServer:
yield server


@pytest.fixture
def ssh_keypair(tmp_path: Path) -> Path:
if shutil.which("ssh-keygen") is None:
pytest.skip("ssh-keygen is required for SSH public publish tests")

key_path = tmp_path / "id_ed25519"
subprocess.run(
["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key_path), "-C", "roar-test"],
check=True,
capture_output=True,
)
return key_path


def _configure_unbound_repo(repo: Path, roar_cli, fake_glaas_url: str) -> dict[str, str]:
subprocess.run(
["git", "remote", "add", "origin", "https://github.com/test/repo.git"],
Expand Down Expand Up @@ -55,6 +71,41 @@ def _configure_unbound_repo(repo: Path, roar_cli, fake_glaas_url: str) -> dict[s
return env


def _configure_public_repo(
repo: Path, roar_cli, fake_glaas_url: str, *, bind_repo: bool
) -> dict[str, str]:
subprocess.run(
["git", "remote", "add", "origin", "https://github.com/test/repo.git"],
cwd=repo,
capture_output=True,
check=True,
)
home_dir = repo / ".home"
home_dir.mkdir(exist_ok=True)
env = {
"HOME": str(home_dir),
"XDG_CONFIG_HOME": str(repo / ".xdg"),
"GLAAS_API_URL": fake_glaas_url,
"ROAR_ENABLE_EXPERIMENTAL_ACCOUNT_COMMANDS": "1",
}
roar_cli("config", "set", "glaas.url", fake_glaas_url, env_overrides=env)
roar_cli("config", "set", "glaas.web_url", fake_glaas_url, env_overrides=env)
if bind_repo:
config_path = repo / ".roar" / "config.toml"
with config_path.open("a", encoding="utf-8") as handle:
handle.write("\n[treqs]\n")
handle.write('owner_id = "owner-test"\n')
handle.write('owner_type = "organization"\n')
handle.write('project_id = "proj-test"\n')
return env


def _parse_session_hash(output: str) -> str:
match = re.search(r"/dag/([0-9a-f]{64})", output)
assert match is not None, f"Missing session URL in output: {output}"
return match.group(1)


def _create_register_fixture(
repo: Path, roar_cli, git_commit, python_exe: str, env: dict[str, str]
) -> None:
Expand Down Expand Up @@ -177,3 +228,90 @@ def test_put_public_succeeds_without_repo_binding_when_public_flag_is_set(
assert result.returncode == 0
assert len(fake_glaas_publish_server.session_registrations) == 1
assert "scope_request" not in fake_glaas_publish_server.session_registrations[0]


def test_register_public_with_valid_ssh_uses_authenticated_creator_identity_for_hash_and_registration(
temp_git_repo: Path,
roar_cli,
git_commit,
python_exe: str,
fake_glaas_publish_server: FakeGlaasServer,
ssh_keypair: Path,
) -> None:
env = _configure_public_repo(
temp_git_repo,
roar_cli,
fake_glaas_publish_server.base_url,
bind_repo=False,
)
_create_register_fixture(temp_git_repo, roar_cli, git_commit, python_exe, env)

anonymous_preview = roar_cli(
"register",
"report.txt",
"--dry-run",
"--yes",
"--public",
env_overrides=env,
)
anonymous_hash = _parse_session_hash(anonymous_preview.stdout)

ssh_env = {**env, "ROAR_SSH_KEY": str(ssh_keypair)}
ssh_preview = roar_cli(
"register",
"report.txt",
"--dry-run",
"--yes",
"--public",
env_overrides=ssh_env,
)
ssh_hash = _parse_session_hash(ssh_preview.stdout)

assert ssh_hash != anonymous_hash

result = roar_cli("register", "report.txt", "--yes", "--public", env_overrides=ssh_env)

assert result.returncode == 0
assert len(fake_glaas_publish_server.session_registrations) == 1
registration = fake_glaas_publish_server.session_registrations[0]
assert registration["hash"] == ssh_hash
assert registration["_authenticated_user_id"] == "ssh-user-123"
assert registration["_auth_mode"] == "ssh"
assert "scope_request" not in registration
assert any(
entry["path"] == "/api/v1/auth/me"
and str(entry.get("authorization") or "").startswith("Signature ")
for entry in fake_glaas_publish_server.auth_headers
)


def test_register_public_with_valid_ssh_ignores_existing_repo_binding(
temp_git_repo: Path,
roar_cli,
git_commit,
python_exe: str,
fake_glaas_publish_server: FakeGlaasServer,
ssh_keypair: Path,
) -> None:
env = _configure_public_repo(
temp_git_repo,
roar_cli,
fake_glaas_publish_server.base_url,
bind_repo=True,
)
_create_register_fixture(temp_git_repo, roar_cli, git_commit, python_exe, env)

result = roar_cli(
"register",
"report.txt",
"--yes",
"--public",
env_overrides={**env, "ROAR_SSH_KEY": str(ssh_keypair)},
)

assert result.returncode == 0
assert len(fake_glaas_publish_server.session_registrations) == 1
registration = fake_glaas_publish_server.session_registrations[0]
assert registration["_authenticated_user_id"] == "ssh-user-123"
assert registration["_auth_mode"] == "ssh"
assert "scope_request" not in registration
Loading
Loading