From 16b569f56b4606b6227f84658770cabf52fefe8f Mon Sep 17 00:00:00 2001 From: cyh Date: Sat, 22 Aug 2026 18:41:45 +0800 Subject: [PATCH] Retire legacy Show Page email authorization --- core/show_pages.py | 15 +- storage/resource_access_service.py | 3 - .../show_page_email_access.py | 163 ------------- tests/scenarios/auth_setup/catalog.yaml | 7 - .../auth_setup/test_auth_setup_scenarios.py | 64 ++---- tests/test_instance_authorization.py | 17 -- tests/test_permissions.py | 93 -------- tests/test_remote_access_vibe_cloud.py | 16 -- tests/test_remote_authorization_revision.py | 217 ------------------ tests/test_resource_acl_show_pages.py | 35 +-- tests/test_ui_show_pages.py | 52 ++--- vibe/api.py | 10 +- vibe/authorization.py | 24 +- vibe/remote_access.py | 121 ++-------- vibe/ui_server.py | 179 +-------------- 15 files changed, 76 insertions(+), 940 deletions(-) delete mode 100644 tests/scenario_harness/show_page_email_access.py diff --git a/core/show_pages.py b/core/show_pages.py index 28674946c5..2674142668 100644 --- a/core/show_pages.py +++ b/core/show_pages.py @@ -692,11 +692,7 @@ def require_show_page_access_management( def _instance_editor_or_owner(context: Any) -> bool: - """The /show Workbench management capability is the Instance Editor role. - - ``show_page_email`` sessions are Viewer-only, so they can never satisfy an - Editor check. Instance Owner passes as the top of the role ladder. - """ + """The /show Workbench management capability is the Instance Editor role.""" return bool(context is not None and context.has_role("editor")) @@ -1014,8 +1010,7 @@ def require_access(self, session_id: str, *, user_context: Any = None) -> ShowPa """Return a Show Page only to an Instance Viewer (owner/editor/viewer). ``/show`` admission is the Instance role alone, independent of the - sharing list and of Resource ACL (§3.2): any Viewer enters the Workbench, - while a signed ``show_page_email`` session never does. + sharing list and of Resource ACL (§3.2): any Viewer enters the Workbench. """ session_id = validate_session_id(session_id) @@ -1096,11 +1091,7 @@ def list_page( @staticmethod def _require_resource_access(user_context: Any) -> None: - if not ( - user_context is not None - and user_context.has_role("viewer") - and user_context.instance_access_source != "show_page_email" - ): + if not (user_context is not None and user_context.has_role("viewer")): raise ShowPageError("Show Page access is not permitted.", code="resource_access_forbidden") @staticmethod diff --git a/storage/resource_access_service.py b/storage/resource_access_service.py index f998250cb2..f8de1f4c2a 100644 --- a/storage/resource_access_service.py +++ b/storage/resource_access_service.py @@ -282,7 +282,6 @@ def metadata_with_resource_user_context( "vibe_instance_role": context.instance_role, "vibe_instance_access_source": context.instance_access_source, "vibe_instance_kind": context.instance_kind, - "vibe_show_page_id": context.show_page_id, "claims_issued_at": context.claims_issued_at, "vibe_instance_authorization_revision": context.authorization_revision, "authorization_expires_at": _resource_context_expires_at(context), @@ -1335,8 +1334,6 @@ def _policy_allows( if resource_kind in {"skill", "vault_secret"}: if context.is_remote and context.is_active_organization_member and context.has_role("editor"): return True - if context.instance_access_source == "show_page_email": - return False if not context.can_use_resource(resource_kind): return False if policy is None: diff --git a/tests/scenario_harness/show_page_email_access.py b/tests/scenario_harness/show_page_email_access.py deleted file mode 100644 index 097c3722c6..0000000000 --- a/tests/scenario_harness/show_page_email_access.py +++ /dev/null @@ -1,163 +0,0 @@ -from __future__ import annotations - -import os -import tempfile -from pathlib import Path -from unittest.mock import patch - -import httpx - -from config.v2_config import ( - AgentsConfig, - PlatformsConfig, - RemoteAccessConfig, - RuntimeConfig, - SlackConfig, - UiConfig, - V2Config, -) -from core.show_pages import ShowPageStore -from vibe import remote_access, ui_server -from vibe.ui_server import app - - -REMOTE_ORIGIN = "https://alex.avibe.bot" -REMOTE_PEER = {"REMOTE_ADDR": "203.0.113.10"} - - -class ShowPageEmailAccessScenarioHarness: - """Hermetic browser harness for one exact-email Show Page login.""" - - def __init__(self) -> None: - self._tempdir = tempfile.TemporaryDirectory() - self._environment = patch.dict( - os.environ, - {"AVIBE_HOME": str(Path(self._tempdir.name) / "avibe-home")}, - ) - self._environment.start() - remote_access._oauth_handshakes.clear() - self.config = self._save_config() - self.client = app.test_client() - store = ShowPageStore() - try: - store.ensure("session-one") - store.ensure("session-two") - finally: - store.close() - - def _save_config(self) -> V2Config: - config = V2Config( - mode="self_host", - version="v2", - platform="slack", - platforms=PlatformsConfig(enabled=["slack"], primary="slack"), - slack=SlackConfig(bot_token=""), - runtime=RuntimeConfig(default_cwd="."), - agents=AgentsConfig(), - ui=UiConfig(), - remote_access=RemoteAccessConfig(), - ) - cloud = config.remote_access.vibe_cloud - cloud.enabled = True - cloud.public_url = REMOTE_ORIGIN - cloud.client_id = "vr_client_123" - cloud.instance_id = "inst_123" - cloud.session_secret = "scenario-session-secret" - cloud.authorization_endpoint = "https://avibe.bot/oauth/authorize" - cloud.redirect_uri = f"{REMOTE_ORIGIN}/auth/callback" - config.save() - return config - - def begin_login(self, show_page_id: str) -> dict[str, str]: - next_path = f"/show/{show_page_id}/__show/me" - navigation = self.client.get( - next_path, - base_url=REMOTE_ORIGIN, - environ_base=REMOTE_PEER, - headers={"Accept": "text/html"}, - follow_redirects=False, - ) - assert navigation.status_code == 302 - authorize_url = navigation.headers["Location"] - if authorize_url.startswith("/auth/login?"): - response = self.client.get( - authorize_url, - base_url=REMOTE_ORIGIN, - environ_base=REMOTE_PEER, - follow_redirects=False, - ) - assert response.status_code == 302 - authorize_url = response.headers["Location"] - authorize_params = httpx.URL(authorize_url).params - state = authorize_params["state"] - state_payload = ui_server._read_oauth_state( - self.config.remote_access.vibe_cloud.session_secret, - state, - ) - assert state_payload is not None - handshake = remote_access._oauth_handshakes[state_payload["r"]] - return { - "next_path": next_path, - "show_page_id": authorize_params["show_page_id"], - "state": state, - "nonce": handshake["nonce"], - } - - def seed_broader_session(self) -> None: - cookie = remote_access.make_session_cookie( - self.config, - "guest@example.com", - "guest-1", - session_claims={ - "vibe_instance_id": "inst_123", - "vibe_instance_role": "editor", - "vibe_instance_access_source": "email", - }, - ) - self.client.set_cookie( - remote_access.SESSION_COOKIE_NAME, - cookie, - domain="alex.avibe.bot", - ) - - def complete_login( - self, - handshake: dict[str, str], - *, - instance_role: str = "viewer", - access_source: str = "show_page_email", - ): - show_page_id = handshake["show_page_id"] - exchange_result = { - "claims": { - "email": "guest@example.com", - "sub": "guest-1", - "nonce": handshake["nonce"], - }, - "session_claims": { - "vibe_instance_id": "inst_123", - "vibe_instance_role": instance_role, - "vibe_instance_access_source": access_source, - "vibe_show_page_id": show_page_id, - }, - } - with patch.object(remote_access, "exchange_oauth_code", return_value=exchange_result): - return self.client.get( - f"/auth/callback?code=scenario-code&state={handshake['state']}", - base_url=REMOTE_ORIGIN, - environ_base=REMOTE_PEER, - follow_redirects=False, - ) - - def get(self, path: str): - return self.client.get( - path, - base_url=REMOTE_ORIGIN, - environ_base=REMOTE_PEER, - follow_redirects=False, - ) - - def close(self) -> None: - remote_access._oauth_handshakes.clear() - self._environment.stop() - self._tempdir.cleanup() diff --git a/tests/scenarios/auth_setup/catalog.yaml b/tests/scenarios/auth_setup/catalog.yaml index bd88b67c94..3b1a084068 100644 --- a/tests/scenarios/auth_setup/catalog.yaml +++ b/tests/scenarios/auth_setup/catalog.yaml @@ -169,13 +169,6 @@ scenarios: layer: scenario backend: web test: tests/scenarios/auth_setup/test_auth_setup_scenarios.py::test_remote_web_oauth_cold_launch_retry_is_single_owner - - id: AUTH-SETUP-401 - name: Exact-email Show Page login stays a /p-only reader and never enters /show - status: covered - kind: authorization - layer: scenario - backend: show_page_email - test: tests/scenarios/auth_setup/test_auth_setup_scenarios.py::ShowPageEmailAccessScenarioTests::test_exact_email_login_is_confined_to_its_signed_show_page - id: AUTH-SETUP-402 name: Personal remote activity slides past the original identity deadline without another authorization prompt status: covered diff --git a/tests/scenarios/auth_setup/test_auth_setup_scenarios.py b/tests/scenarios/auth_setup/test_auth_setup_scenarios.py index e37dbd93f6..0da42bdadb 100644 --- a/tests/scenarios/auth_setup/test_auth_setup_scenarios.py +++ b/tests/scenarios/auth_setup/test_auth_setup_scenarios.py @@ -40,7 +40,6 @@ from modules.agents.codex.agent import CodexAgent from tests.scenario_harness.auth_setup import AuthSetupScenarioHarness, FakeProcess from tests.scenario_harness.core import ScenarioExpect, ScenarioRunner, ScenarioStep -from tests.scenario_harness.show_page_email_access import ShowPageEmailAccessScenarioHarness from tests.ui_server_test_helpers import _save_config, remote_session_cookie from storage import remote_access_authorization_service from tests.scenario_harness.model_hub_native_oauth import ( @@ -68,41 +67,6 @@ def test_auth_setup_catalog_priorities_reference_live_scenarios(): assert set(catalog.get("next_priority", [])) <= live_ids -class ShowPageEmailAccessScenarioTests(unittest.TestCase): - def setUp(self): - self.harness = ShowPageEmailAccessScenarioHarness() - self.addCleanup(self.harness.close) - - def test_exact_email_login_is_confined_to_its_signed_show_page(self): - """Scenario: AUTH-SETUP-401""" - handshake = self.harness.begin_login("session-one") - self.assertEqual(handshake["show_page_id"], "session-one") - - callback = self.harness.complete_login(handshake) - self.assertEqual(callback.status_code, 302) - self.assertEqual(callback.headers["Location"], handshake["next_path"]) - - exact = self.harness.get(handshake["next_path"]) - other = self.harness.get("/show/session-two/__show/me") - api = self.harness.get("/api/show-pages") - # §3.2: a show_page_email grant is a /p-only read visitor — it never - # enters the /show surface, even for its own signed page. - self.assertEqual(exact.status_code, 403) - self.assertEqual(other.status_code, 403) - self.assertEqual(other.get_json()["error"], "show_page_access_forbidden") - self.assertEqual(api.status_code, 403) - - self.harness.seed_broader_session() - # §3.2: a real Instance Editor session (email-sourced, not a show_page - # grant) enters /show directly, with no login handshake and independent - # of any show_page entitlement. - self.assertEqual( - self.harness.get("/show/session-one/__show/me").status_code, - 200, - ) - self.assertTrue(self.harness.get("/show/session-one/__show/me").get_json()["authenticated"]) - - def test_limited_show_identity_closed_loop_installs_guest_lease(monkeypatch, tmp_path): """Scenario: AUTH-SETUP-404""" monkeypatch.setenv("AVIBE_HOME", str(tmp_path)) @@ -112,6 +76,11 @@ def test_limited_show_identity_closed_loop_installs_guest_lease(monkeypatch, tmp cloud.issuer = "https://backend.test" cloud.jwks_uri = "https://backend.test/oauth/jwks.json" config.save() + monkeypatch.setattr( + ShowPageStore, + "_resolve_instance_ownership", + staticmethod(lambda: {"mode": "organization", "organization_id": "组织-甲"}), + ) store = ShowPageStore() try: @@ -123,7 +92,13 @@ def test_limited_show_identity_closed_loop_installs_guest_lease(monkeypatch, tmp expected_revision=access.revision, target_access_mode="limited", target_share_id=page.share_id, - target_emails=["viewer@example.com"], + target_entries=[ + { + "kind": "group", + "value": "研发组", + "organization_id": "组织-甲", + } + ], ) assert applied.status == "applied" finally: @@ -153,13 +128,17 @@ def test_limited_show_identity_closed_loop_installs_guest_lease(monkeypatch, tmp { "iss": cloud.issuer, "aud": f"avibe-show-identity:{cloud.client_id}", - "sub": "viewer-1", + "sub": "访客-甲", "iat": issued_at, "exp": issued_at + 300, "jti": f"scenario-{time.time_ns()}", "nonce": nonce, "instance_id": cloud.instance_id, "verified_email": "viewer@example.com", + "organization_id": "组织-甲", + "organization_member_id": "成员-甲", + "organization_role": "member", + "group_ids": ["研发组"], }, private_key, algorithm="RS256", @@ -227,13 +206,8 @@ def get_signing_key_from_jwt(self, token): remote_session_cookie( config, "viewer@example.com", - "viewer-1", - session_claims={ - "vibe_instance_id": cloud.instance_id, - "vibe_instance_role": "viewer", - "vibe_instance_access_source": "show_page_email", - "vibe_show_page_id": page.session_id, - }, + "访客-甲", + role="viewer", ), domain="alex.avibe.bot", ) diff --git a/tests/test_instance_authorization.py b/tests/test_instance_authorization.py index 11792f3100..1e219078a5 100644 --- a/tests/test_instance_authorization.py +++ b/tests/test_instance_authorization.py @@ -200,23 +200,6 @@ def test_context_from_session_payload_only_recognizes_known_instance_kinds() -> assert not unknown.is_personal_instance -def test_show_page_email_context_is_exactly_page_scoped() -> None: - context = context_from_session_payload( - { - "sub": "guest-1", - "vibe_instance_role": "viewer", - "vibe_instance_access_source": "show_page_email", - "vibe_show_page_id": "session-one", - } - ) - assert not context.can_read_instance - assert context.capability_projection()["can_read_instance"] is False - assert context.capability_projection()["can_use_show_pages"] is False - assert context.can_use_show_page("session-one") - assert not context.can_use_show_page("session-two") - assert not context.can_chat - - def test_http_policy_is_role_only_and_unknown_api_routes_fail_closed() -> None: editor_routes = ( ("GET", "/api/agents"), diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 00d2272efc..f8ec9ffe46 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -2363,54 +2363,6 @@ def test_permissions_projection_get_is_private_and_not_cached(monkeypatch) -> No assert response.get_json()["projection"]["instance"]["id"] == "inst-123" -def test_permissions_projection_rejects_page_scoped_guest_before_backend( - monkeypatch, - tmp_path, -) -> None: - monkeypatch.setenv("AVIBE_HOME", str(tmp_path)) - config = save_config(tmp_path) - backend_called = False - - def get_current_permissions(): - nonlocal backend_called - backend_called = True - return permissions.PermissionsProjectionResult( - projection=_projection(), - source="live", - ) - - monkeypatch.setattr(permissions, "get_current_permissions", get_current_permissions) - client = app.test_client() - client.set_cookie( - remote_access.SESSION_COOKIE_NAME, - remote_session_cookie( - config, - "guest@example.com", - "guest-1", - session_claims={ - "vibe_instance_id": config.remote_access.vibe_cloud.instance_id, - "vibe_instance_role": "viewer", - "vibe_instance_access_source": "show_page_email", - "vibe_show_page_id": "session-one", - }, - ), - domain="alex.avibe.bot", - ) - - response = client.get( - "/api/permissions", - base_url="https://alex.avibe.bot", - environ_base=remote_peer(), - ) - - assert response.status_code == 403 - assert response.get_json() == { - "ok": False, - "error": "show_page_access_forbidden", - } - assert backend_called is False - - def test_permissions_same_origin_routes_reject_non_contract_entry_fields(monkeypatch) -> None: client = app.test_client() headers = csrf_headers(client) @@ -2776,51 +2728,6 @@ def update_resource(*_args): assert called is False -def test_resource_access_route_rejects_page_scoped_guest_before_backend( - monkeypatch, - tmp_path, -) -> None: - monkeypatch.setenv("AVIBE_HOME", str(tmp_path)) - config = save_config(tmp_path) - backend_called = False - - def get_resource(*_args): - nonlocal backend_called - backend_called = True - return {"resource": _resource()} - - monkeypatch.setattr(permissions, "get_resource_access", get_resource) - client = app.test_client() - client.set_cookie( - remote_access.SESSION_COOKIE_NAME, - remote_session_cookie( - config, - "guest@example.com", - "guest-1", - session_claims={ - "vibe_instance_id": config.remote_access.vibe_cloud.instance_id, - "vibe_instance_role": "viewer", - "vibe_instance_access_source": "show_page_email", - "vibe_show_page_id": "agent-1", - }, - ), - domain="alex.avibe.bot", - ) - - response = client.get( - "/api/permissions/resources/agent/agent-1/access", - base_url="https://alex.avibe.bot", - environ_base=remote_peer(), - ) - - assert response.status_code == 403 - assert response.get_json() == { - "ok": False, - "error": "show_page_access_forbidden", - } - assert backend_called is False - - def test_resource_access_route_surfaces_cloud_authority_failure(monkeypatch) -> None: monkeypatch.setattr( permissions, diff --git a/tests/test_remote_access_vibe_cloud.py b/tests/test_remote_access_vibe_cloud.py index f651ae86d6..f4db332ff5 100644 --- a/tests/test_remote_access_vibe_cloud.py +++ b/tests/test_remote_access_vibe_cloud.py @@ -278,22 +278,6 @@ def test_session_claims_reject_missing_or_unknown_instance_role() -> None: assert claims["vibe_instance_role"] == "member" -@pytest.mark.parametrize("instance_role", ["editor", "member", "owner"]) -def test_session_claims_reject_elevated_show_page_email_roles(instance_role: str) -> None: - config = _config() - - with pytest.raises(remote_access.OAuthCodeExchangeError, match="invalid_instance_role"): - remote_access.session_claims_from_oidc( - config, - { - "vibe_instance_id": "inst_123", - "vibe_instance_role": instance_role, - "vibe_instance_access_source": "show_page_email", - "vibe_show_page_id": "session-one", - }, - ) - - def test_session_cookie_persists_validated_organization_claims() -> None: config = _config() cookie = remote_session_cookie( diff --git a/tests/test_remote_authorization_revision.py b/tests/test_remote_authorization_revision.py index 122d81b381..dc37acfa98 100644 --- a/tests/test_remote_authorization_revision.py +++ b/tests/test_remote_authorization_revision.py @@ -267,37 +267,6 @@ def test_partial_pairing_cannot_restore_cached_personal_authorization(tmp_path): assert resolution.reason == "pairing_unavailable" -def test_partial_pairing_preserves_exact_show_page_email_grant(tmp_path): - config = _paired_config(tmp_path) - config.remote_access.vibe_cloud.instance_kind = "personal" - config.remote_access.vibe_cloud.instance_secret = "" - config.save() - cookie = remote_access.make_session_cookie( - config, - "viewer@example.com", - "viewer-1", - session_claims={ - "vibe_instance_id": "inst_123", - "vibe_instance_role": "viewer", - "vibe_instance_access_source": "show_page_email", - "vibe_show_page_id": "show-1", - "vibe_instance_authorization_revision": 41, - }, - ) - identity = remote_access.parse_session_identity(config, cookie) - assert identity is not None - - resolution = remote_access.resolve_current_authorization( - config, - identity, - allow_refresh=False, - ) - - assert resolution.current is True - assert resolution.payload is not None - assert resolution.payload["vibe_show_page_id"] == "show-1" - - def test_organization_to_personal_reclassification_revalidates_before_the_bypass( monkeypatch, tmp_path, @@ -694,102 +663,6 @@ def generation(*, ensure: bool = True) -> int: assert stored.get("authorization_state") != "revoked" -def test_exact_show_page_grants_survive_a_kind_transition_but_not_a_repair(tmp_path): - """Show Page grants are their own scope, bound to the instance that issued them.""" - - config = _paired_config(tmp_path) - remote_access._transition_instance_binding( - instance_id="inst_123", - instance_kind="organization", - ) - now = int(time.time()) - show_page_claims = { - "vibe_instance_id": "inst_123", - "vibe_instance_role": "viewer", - "vibe_instance_access_source": "show_page_email", - "vibe_show_page_id": "show-1", - "vibe_instance_authorization_revision": 41, - } - show_page_cookie = remote_access.make_session_cookie( - config, - "viewer@example.com", - "viewer-1", - session_claims=show_page_claims, - ) - show_page_identity = remote_access.parse_session_identity(config, show_page_cookie) - assert show_page_identity is not None - instance_identity = remote_access.parse_session_identity( - config, - _organization_cookie(config), - ) - assert instance_identity is not None - - assert remote_access._persist_instance_kind("inst_123", "personal", reconcile=True) - - reclassified = V2Config.load() - show_page = remote_access_authorization_service.load_scoped( - instance_id="inst_123", - subject="viewer-1", - scope_kind="show_page", - scope_ref="show-1", - ) - instance = remote_access_authorization_service.load_scoped( - instance_id="inst_123", - subject="user-1", - scope_kind="instance", - scope_ref="inst_123", - ) - assert show_page is not None - assert show_page["authorization_state"] == "current" - assert show_page["claims"]["vibe_show_page_id"] == "show-1" - assert instance is not None - assert instance["authorization_state"] == "stale" - - resolved = remote_access.resolve_current_authorization( - reclassified, - show_page_identity, - allow_refresh=False, - ) - assert resolved.current is True - assert resolved.payload is not None - assert resolved.payload["vibe_show_page_id"] == "show-1" - - # Mid-transition the exact grant still reads, while the instance-scoped - # cache stays fail-closed. - reconciling = remote_access_authorization_service.begin_instance_binding_transition( - instance_id="inst_repair", - instance_kind="personal", - ) - assert ( - reconciling["state"] - == remote_access_authorization_service.INSTANCE_BINDING_STATE_RECONCILING - ) - assert ( - remote_access.binding_is_ready(reclassified, show_page_identity) - is True - ) - assert ( - remote_access.binding_is_ready(reclassified, instance_identity) - is False - ) - - # Re-pairing to a different instance is not a reclassification: those - # grants were issued by the previous instance and must not survive it. - remote_access._transition_instance_binding( - instance_id="inst_repair", - instance_kind="personal", - previous_instance_id="inst_123", - ) - repaired = remote_access_authorization_service.load_scoped( - instance_id="inst_123", - subject="viewer-1", - scope_kind="show_page", - scope_ref="show-1", - ) - assert repaired is not None - assert repaired["authorization_state"] == "stale" - - def test_personal_revision_hint_refreshes_in_background_without_blocking( monkeypatch, tmp_path, @@ -956,96 +829,6 @@ def test_unknown_instance_kind_requires_durable_backfill_before_access( assert config.remote_access.vibe_cloud.instance_kind == "" -def test_scoped_authorization_promotes_legacy_rows_and_isolates_show_pages(tmp_path): - now = int(time.time()) - remote_access_authorization_service.store( - reference="legacy-reference-12345678", - instance_id="inst_123", - subject="user-1", - claims={"vibe_instance_id": "inst_123"}, - expires_at=now + 60, - created_at=now, - ) - - instance_reference = remote_access_authorization_service.upsert_scoped( - reference="legacy-reference-12345678", - instance_id="inst_123", - subject="user-1", - email="user@example.com", - scope_kind="instance", - scope_ref="inst_123", - authorization_state="current", - claims={"scope": "instance"}, - last_checked_at=now + 1, - updated_at=now + 1, - ) - show_reference = remote_access_authorization_service.upsert_scoped( - reference=None, - instance_id="inst_123", - subject="user-1", - email="user@example.com", - scope_kind="show_page", - scope_ref="show-1", - authorization_state="current", - claims={"scope": "show_page"}, - last_checked_at=now + 2, - updated_at=now + 2, - ) - - instance = remote_access_authorization_service.load_scoped( - instance_id="inst_123", - subject="user-1", - scope_kind="instance", - scope_ref="inst_123", - ) - show_page = remote_access_authorization_service.load_scoped( - instance_id="inst_123", - subject="user-1", - scope_kind="show_page", - scope_ref="show-1", - ) - assert instance_reference == "legacy-reference-12345678" - assert show_reference != instance_reference - assert instance is not None and instance["claims"] == {"scope": "instance"} - assert instance["expires_at"] is None - assert show_page is not None and show_page["claims"] == {"scope": "show_page"} - - -def test_referenced_identity_does_not_guess_scope_when_record_is_missing( - monkeypatch, - tmp_path, -): - config = _paired_config(tmp_path) - cookie = remote_access.make_session_cookie( - config, - "viewer@example.com", - "viewer-1", - session_claims={ - "vibe_instance_id": "inst_123", - "vibe_instance_role": "viewer", - "vibe_instance_access_source": "show_page_email", - "vibe_show_page_id": "show-1", - "vibe_instance_authorization_revision": 41, - }, - ) - identity = remote_access.parse_session_identity(config, cookie) - assert identity is not None - assert isinstance(identity.get("authorization_ref"), str) - assert remote_access_authorization_service.delete_for_instance("inst_123") == 1 - monkeypatch.setattr( - remote_access, - "_device_json_request", - lambda *args, **kwargs: (_ for _ in ()).throw( - AssertionError("missing referenced records must not guess a refresh scope") - ), - ) - - result = remote_access.resolve_current_authorization(config, identity) - - assert result.state == "invalid_identity" - assert result.reason == "authorization_record_missing" - - def test_authorization_record_read_failure_is_unavailable(monkeypatch, tmp_path): config = _paired_config(tmp_path) identity = remote_access.parse_session_identity(config, _organization_cookie(config)) diff --git a/tests/test_resource_acl_show_pages.py b/tests/test_resource_acl_show_pages.py index b57af15b5d..f56b48044a 100644 --- a/tests/test_resource_acl_show_pages.py +++ b/tests/test_resource_acl_show_pages.py @@ -116,24 +116,6 @@ def test_show_require_access_follows_instance_role_alone( store.close() -def test_show_require_access_denies_show_page_email_source(monkeypatch, tmp_path) -> None: - monkeypatch.setenv("AVIBE_HOME", str(tmp_path)) - store = _seed_show_pages() - context = resource_access_service.ResourceUserContext( - subject="guest-1", - email="guest@example.com", - instance_role="viewer", - instance_access_source="show_page_email", - show_page_id="ses-private", - is_remote=True, - ) - try: - with pytest.raises(ShowPageError, match="Show Page access is not permitted"): - store.require_access("ses-private", user_context=context) - finally: - store.close() - - @pytest.mark.parametrize( ("subject", "instance_role", "organization_role"), [ @@ -204,7 +186,7 @@ async def _runtime_response(*args, **kwargs): assert all(not item["can_manage"] for item in pages) assert all(not item["can_publish_public"] for item in pages) assert mutation.status_code == (200 if instance_role == "owner" else 403) - # §3.2: every Instance Viewer enters /show; only show_page_email is barred. + # §3.2: every Instance Viewer enters /show. assert page.status_code == 200 @@ -494,7 +476,7 @@ def test_existing_show_page_is_adopted_idempotently_without_changing_link_access store.close() -def test_show_page_access_api_follows_instance_role_and_bars_email_guests( +def test_show_page_access_api_follows_instance_role( monkeypatch, tmp_path, ) -> None: @@ -509,19 +491,6 @@ def test_show_page_access_api_follows_instance_role_and_bars_email_guests( lambda: _ownership("organization", organization_id="org-1"), ) - page_guest = resource_access_service.ResourceUserContext( - subject="guest-1", - email="guest@example.com", - instance_role="viewer", - instance_access_source="show_page_email", - show_page_id="ses-access-meta", - is_remote=True, - ) - # §3.2: a signed show_page_email guest is a /p-only visitor — it never - # reads access metadata. - with pytest.raises(ShowPageError, match="Show Page access is not permitted"): - api.get_show_page_access("ses-access-meta", user_context=page_guest) - instance_editor = _organization_context("editor-1", instance_role="editor") response = api.get_show_page_access( "ses-access-meta", diff --git a/tests/test_ui_show_pages.py b/tests/test_ui_show_pages.py index 2e53c0c96b..b76bb17c3e 100644 --- a/tests/test_ui_show_pages.py +++ b/tests/test_ui_show_pages.py @@ -67,25 +67,6 @@ def _active_org_cookie(config, email="member@example.com", subject="member-1", * ) -def _show_page_email_cookie( - config, - session_id="ses123", - email="viewer@example.com", - subject="viewer-1", -): - return remote_session_cookie( - config, - email, - subject, - session_claims={ - "vibe_instance_id": config.remote_access.vibe_cloud.instance_id, - "vibe_instance_role": "viewer", - "vibe_instance_access_source": "show_page_email", - "vibe_show_page_id": session_id, - }, - ) - - class _FakeShowRuntimeManager: def __init__( self, @@ -695,11 +676,11 @@ def fail_oauth(*_args, **_kwargs): page_scoped_client = app.test_client() page_scoped_client.set_cookie( remote_access.SESSION_COOKIE_NAME, - _show_page_email_cookie( + remote_session_cookie( config, - session_id="other-page", - email="other@example.com", - subject="other-viewer", + "other@example.com", + "other-viewer", + role="viewer", ), domain="alex.avibe.bot", ) @@ -711,7 +692,6 @@ def fail_oauth(*_args, **_kwargs): follow_redirects=False, ) assert page_scoped.status_code == 403 - assert 'href="/"' not in page_scoped.text def test_limited_show_callback_maps_outages_and_rechecks_share_binding( @@ -1275,11 +1255,6 @@ def verify_identity(*_args, **_kwargs): query = urllib.parse.parse_qs( urllib.parse.urlsplit(login.headers["Location"]).query ) - client.set_cookie( - remote_access.SESSION_COOKIE_NAME, - _show_page_email_cookie(config), - domain="alex.avibe.bot", - ) callback = client.post( show_identity.CALLBACK_PATH, base_url="https://alex.avibe.bot", @@ -3751,7 +3726,7 @@ def test_private_show_me_is_always_available(monkeypatch, tmp_path): assert response.headers["cache-control"] == "no-store, private" -def test_private_show_page_bars_show_page_email_viewer_from_show_surface(monkeypatch, tmp_path): +def test_private_show_page_allows_instance_viewer_read_access(monkeypatch, tmp_path): monkeypatch.setenv("AVIBE_HOME", str(tmp_path)) config = _save_config(tmp_path) _create_agent_session("ses123") @@ -3763,7 +3738,12 @@ def test_private_show_page_bars_show_page_email_viewer_from_show_surface(monkeyp client = app.test_client() client.set_cookie( remote_access.SESSION_COOKIE_NAME, - _show_page_email_cookie(config, email="viewer@example.com", subject="user-viewer"), + remote_session_cookie( + config, + "viewer@example.com", + "user-viewer", + role="viewer", + ), domain="alex.avibe.bot", ) try: @@ -3780,10 +3760,12 @@ def test_private_show_page_bars_show_page_email_viewer_from_show_surface(monkeyp finally: set_show_runtime_manager_for_tests(None) - # §3.2: a signed show_page_email grant is a /p-only visitor — it never - # enters the private /show surface, even for its own signed page. - assert me_response.status_code == 403 - assert page_response.status_code == 403 + assert me_response.status_code == 200 + assert me_response.get_json() == { + "authenticated": False, + "canAnnotate": False, + } + assert page_response.status_code == 200 def test_public_show_me_is_anonymous_without_oauth_session(monkeypatch, tmp_path): diff --git a/vibe/api.py b/vibe/api.py index 4ac3ccca48..73d92df4a2 100644 --- a/vibe/api.py +++ b/vibe/api.py @@ -1722,10 +1722,7 @@ def _show_page_mutation_response( from storage import resource_access_service context = resource_access_service.resolve_resource_access_context(user_context) - can_use = ( - context.has_role("viewer") - and context.instance_access_source != "show_page_email" - ) + can_use = context.has_role("viewer") if not can_use: # Access managers may take a page offline without page-use access. Do # not return page paths, URLs, share IDs, audience, or session metadata. @@ -1832,10 +1829,7 @@ def get_show_page_access(session_id: str, *, user_context: Any = None) -> dict: if page is None: raise ShowPageError("This session has no Show Page.", code="show_page_not_found") reconciliation = store.reconcile_resource_policy(page.session_id) - can_use = ( - context.has_role("viewer") - and context.instance_access_source != "show_page_email" - ) + can_use = context.has_role("viewer") can_manage = context.has_role("editor") can_publish_public = context.has_role("editor") if not (can_use or can_manage): diff --git a/vibe/authorization.py b/vibe/authorization.py index 2c6507e607..03f375aea0 100644 --- a/vibe/authorization.py +++ b/vibe/authorization.py @@ -21,7 +21,6 @@ "email", "email_domain", "organization_group", - "show_page_email", } ) ORGANIZATION_ROLES = frozenset({"owner", "admin", "member"}) @@ -103,7 +102,6 @@ class AuthorizationContext: membership_version: str | None = None claims_issued_at: int | None = None authorization_revision: int | None = None - show_page_id: str | None = None is_remote: bool = False instance_kind: str | None = None @@ -134,7 +132,7 @@ def has_role(self, minimum_role: str) -> bool: @property def can_read_instance(self) -> bool: - return self.has_role("viewer") and self.instance_access_source != "show_page_email" + return self.has_role("viewer") @property def can_chat(self) -> bool: @@ -171,11 +169,6 @@ def can_use_resource(self, resource_kind: str) -> bool: minimum_role = _RESOURCE_USE_MINIMUM_ROLES.get(resource_kind) return minimum_role is not None and self.has_role(minimum_role) - def can_use_show_page(self, show_page_id: str) -> bool: - """Return whether the signed session carries this exact page entitlement.""" - - return bool(self.show_page_id and self.show_page_id == show_page_id) - @property def can_use_terminal_files(self) -> bool: return self.has_role("editor") @@ -252,11 +245,6 @@ def context_from_session_payload(payload: Mapping[str, Any]) -> AuthorizationCon ) if access_source not in INSTANCE_ACCESS_SOURCES: return AuthorizationContext(is_remote=True) - show_page_id = _optional_string(payload.get("vibe_show_page_id"), limit=200) - if access_source == "show_page_email" and show_page_id is None: - return AuthorizationContext(is_remote=True) - if access_source == "show_page_email" and role != "viewer": - return AuthorizationContext(is_remote=True) raw_groups = payload.get("vibe_group_ids", payload.get("group_ids", [])) group_ids = ( frozenset(value for item in raw_groups if (value := _optional_string(item)) is not None) @@ -299,7 +287,6 @@ def context_from_session_payload(payload: Mapping[str, Any]) -> AuthorizationCon payload.get("authorization_revision"), ) ), - show_page_id=show_page_id, is_remote=True, instance_kind=instance_kind, ) @@ -358,15 +345,6 @@ def can_receive_workbench_event( ) -> bool: """Return whether an SSE subscriber may receive a workbench event.""" - # A signed Show Page email grant is still a viewer session, but its exact - # page subtree is enforced by the payload/resource visibility filters below. - if ( - event_type == "show.event" - and isinstance(context, AuthorizationContext) - and context.instance_access_source == "show_page_email" - and context.can_use_show_page(context.show_page_id or "") - ): - return True try: require_instance_role(context, required_workbench_event_role(event_type)) except InstanceAuthorizationError: diff --git a/vibe/remote_access.py b/vibe/remote_access.py index 34813db9dd..fa62377da5 100644 --- a/vibe/remote_access.py +++ b/vibe/remote_access.py @@ -65,7 +65,7 @@ OAUTH_ID_TOKEN_CLOCK_LEEWAY_SECONDS = 30 _INSTANCE_ACCESS_ROLES = frozenset({"owner", "member", "editor", "viewer"}) _INSTANCE_ACCESS_SOURCES = frozenset( - {"owner", "public_instance", "email", "email_domain", "organization_group", "show_page_email"} + {"owner", "public_instance", "email", "email_domain", "organization_group"} ) _ORGANIZATION_ROLES = frozenset({"owner", "admin", "member"}) _INSTANCE_KINDS = frozenset({"personal", "organization"}) @@ -153,7 +153,7 @@ class _AuthorizationRevisionCache: _RESOURCE_ACL_PENDING_VAULT_RELEASE_PREFIX = "resource_acl_pending_vault_release:" _INSTANCE_ACCESS_ROLES = frozenset({"owner", "member", "editor", "viewer"}) _INSTANCE_ACCESS_SOURCES = frozenset( - {"owner", "public_instance", "email", "email_domain", "organization_group", "show_page_email"} + {"owner", "public_instance", "email", "email_domain", "organization_group"} ) _ORGANIZATION_ROLES = frozenset({"owner", "admin", "member"}) _BLOCKED_PAIRING_BACKEND_HOSTS = { @@ -1668,50 +1668,13 @@ def _known_kind_requires_runtime_pairing(config: V2Config) -> bool: return _normalized_instance_kind(config.remote_access.vibe_cloud.instance_kind) is not None -def _is_exact_show_page_grant( - identity: Mapping[str, Any], - record: Mapping[str, Any] | None = None, -) -> bool: - candidates: list[Mapping[str, Any]] = [identity] - if isinstance(record, Mapping): - claims = record.get("claims") - if isinstance(claims, Mapping): - candidates.append(claims) - return any( - candidate.get("vibe_instance_access_source") == "show_page_email" - and isinstance(candidate.get("vibe_show_page_id"), str) - and bool(candidate["vibe_show_page_id"].strip()) - for candidate in candidates - ) - - def binding_is_ready(config: V2Config, identity: Mapping[str, Any] | None = None) -> bool: """C3: single gate for every authorization consumer. A missing durable row is the fail-open legacy no-kind path. Once a row - exists, only ``ready`` for the current pairing admits kind-specific - bypass. Exact show_page_email grants are independent of instance kind. + exists, only ``ready`` for the current pairing admits kind-specific bypass. """ - if identity is not None: - if _is_exact_show_page_grant(identity): - return True - from storage import remote_access_authorization_service - - instance_id = str(identity.get("instance_id") or config.remote_access.vibe_cloud.instance_id or "") - subject = str(identity.get("sub") or "") - try: - if subject and instance_id: - record = remote_access_authorization_service.load_reference_record( - reference=str(identity.get("authorization_ref") or ""), - instance_id=instance_id, - subject=subject, - now=int(time.time()), - ) - if record is not None and _is_exact_show_page_grant(identity, record): - return True - except Exception: - pass try: from storage import remote_access_authorization_service @@ -4762,20 +4725,6 @@ def session_claims_from_oidc(config: V2Config, claims: Mapping[str, Any]) -> dic "vibe_instance_role": instance_role, "vibe_instance_access_source": access_source, } - show_page_claim_present = "vibe_show_page_id" in claims - if show_page_claim_present: - show_page_id = _oidc_claim_string( - claims.get("vibe_show_page_id"), - reason="invalid_show_page_id", - limit=200, - ) - if "/" in show_page_id or "\\" in show_page_id: - raise OAuthCodeExchangeError("invalid_show_page_id") - session_claims["vibe_show_page_id"] = show_page_id - elif access_source == "show_page_email": - raise OAuthCodeExchangeError("invalid_show_page_id") - if access_source == "show_page_email" and instance_role != "viewer": - raise OAuthCodeExchangeError("invalid_instance_role") raw_authorization_revision = claims.get(_AUTHORIZATION_REVISION_KEY) if raw_authorization_revision is None: if _authorization_revision_sync_configured(config): @@ -4875,22 +4824,10 @@ def _encode_session_cookie(secret: str, payload: Mapping[str, Any]) -> str: "vibe_instance_role", "vibe_instance_access_source", "vibe_instance_authorization_revision", - "vibe_show_page_id", *_ORGANIZATION_SESSION_CLAIM_KEYS, ) -def _authorization_scope( - config: V2Config, - claims: Mapping[str, Any], -) -> tuple[str, str]: - if claims.get("vibe_instance_access_source") == "show_page_email": - show_page_id = claims.get("vibe_show_page_id") - if isinstance(show_page_id, str) and show_page_id: - return "show_page", show_page_id - return "instance", str(config.remote_access.vibe_cloud.instance_id) - - def _authorization_claims_from_payload(payload: Mapping[str, Any]) -> dict[str, Any]: claims = { key: payload[key] @@ -4942,8 +4879,9 @@ def _store_scoped_authorization( ) if persisted_kind is not None: stored_claims["vibe_instance_kind"] = persisted_kind - scope_kind, scope_ref = _authorization_scope(config, stored_claims) - if expected_binding_generation is None and scope_kind != "show_page": + scope_kind = "instance" + scope_ref = str(config.remote_access.vibe_cloud.instance_id or "") + if expected_binding_generation is None: # Recapturing live generation here is the TOCTOU: a transition can # complete between the caller's persist-kind CAS and this write, and # the recaptured gen would let a stale response resurrect a current @@ -5166,7 +5104,8 @@ def _load_authorization_record( if checked_at <= 0: return None claims = {**validated, "claims_issued_at": checked_at} - scope_kind, scope_ref = _authorization_scope(config, claims) + scope_kind = "instance" + scope_ref = instance_id existing = remote_access_authorization_service.load_scoped( instance_id=instance_id, subject=subject, @@ -5226,12 +5165,11 @@ def _validated_authorization_payload( # Callers that already captured the gate pass it in; this function must # not trigger a second live read inside the same evaluation. gate = binding_is_ready(config, identity) if binding_gate is None else binding_gate - if not gate and not _is_exact_show_page_grant(identity, record): + if not gate: return None if ( _known_kind_requires_runtime_pairing(config) and not _runtime_pairing_available(config) - and not _is_exact_show_page_grant(identity, record) ): return None claims = record.get("claims") @@ -5239,9 +5177,7 @@ def _validated_authorization_payload( return None from vibe.authorization import instance_kind_is_unsupported - if instance_kind_is_unsupported( - claims.get("vibe_instance_kind") - ) and not _is_exact_show_page_grant(identity, record): + if instance_kind_is_unsupported(claims.get("vibe_instance_kind")): # A present-but-unrecognized persisted kind (corruption or a newer # release's artifact) is not a legacy no-kind row. Fail closed so the # row revalidates instead of falling through to legacy-current. @@ -5295,8 +5231,6 @@ def _fetch_authorization_context( subject = str(identity.get("sub") or "").strip() email = str(identity.get("email") or "").strip() request_payload: dict[str, Any] = {"sub": subject, "email": email} - if record is not None and record.get("scope_kind") == "show_page": - request_payload["show_page_id"] = record.get("scope_ref") try: response = _device_json_request( config, @@ -5696,7 +5630,6 @@ def resolve_current_authorization( and record is not None and not _runtime_pairing_available(config) and _known_kind_requires_runtime_pairing(config) - and not _is_exact_show_page_grant(identity, record) ): return AuthorizationResolution("unavailable", reason="pairing_unavailable") if payload is None: @@ -5722,30 +5655,25 @@ def resolve_current_authorization( stored_claims = record.get("claims") if isinstance(stored_claims, Mapping): stored_kind = _normalized_instance_kind(stored_claims.get("vibe_instance_kind")) - is_exact_show_page = _is_exact_show_page_grant(identity, record) kindless_current_needs_refresh = ( - not is_exact_show_page - and instance_kind is not None + instance_kind is not None and stored_kind is None and record is not None and record.get("authorization_state") == "current" ) kind_mismatch = ( - not is_exact_show_page - and ( - ( - instance_kind is not None - and stored_kind is not None - and stored_kind != instance_kind - ) - or ( - instance_kind is not None - and stored_kind is None - and record is not None - and record.get("authorization_state") == "stale" - ) - or kindless_current_needs_refresh + ( + instance_kind is not None + and stored_kind is not None + and stored_kind != instance_kind + ) + or ( + instance_kind is not None + and stored_kind is None + and record is not None + and record.get("authorization_state") == "stale" ) + or kindless_current_needs_refresh ) if kind_mismatch: # A row tagged with the previous kind (or invalidated by a @@ -5948,7 +5876,6 @@ def authorization_url( nonce: str, code_challenge: str, redirect_uri: str | None = None, - show_page_id: str | None = None, ) -> str: cloud = config.remote_access.vibe_cloud params = { @@ -5963,8 +5890,6 @@ def authorization_url( } if cloud.dev_login_hint: params["login_hint"] = cloud.dev_login_hint - if show_page_id: - params["show_page_id"] = show_page_id return f"{cloud.authorization_endpoint}?{urllib.parse.urlencode(params)}" @@ -6089,7 +6014,6 @@ def store_oauth_handshake( next_target: str, device_hash: str | None = None, redirect_uri: str | None = None, - show_page_id: str | None = None, ) -> None: """Persist a login handshake in memory, keyed by the signed state's random id. @@ -6105,7 +6029,6 @@ def store_oauth_handshake( "next": next_target, "device_hash": device_hash, "redirect_uri": redirect_uri, - "show_page_id": show_page_id, "exp": now + OAUTH_HANDSHAKE_TTL_SECONDS, } with _OAUTH_STORE_LOCK: diff --git a/vibe/ui_server.py b/vibe/ui_server.py index eb274406ac..db0478b8b3 100644 --- a/vibe/ui_server.py +++ b/vibe/ui_server.py @@ -203,7 +203,6 @@ def _show_runtime_forwarded_headers(headers: Mapping[str, str]) -> dict[str, str ) REMOTE_OAUTH_COOKIE_NAME = "__Host-vibe_remote_oauth" REMOTE_OAUTH_RETRY_PARAM = "__vibe_oauth_retry" -REMOTE_SHOW_PAGE_REAUTH_PARAM = "__vibe_show_page_reauth" # Lifetime of the short-lived OAuth handshake (signed state + PKCE cookie). The # cookie MUST carry an explicit Max-Age: iOS standalone PWAs drop session-scoped # cookies (no Max-Age) across the cross-origin authorize excursion / app @@ -1779,48 +1778,14 @@ def _add_oauth_retry_param(value: str) -> str: return urlunsplit(("", "", parsed.path or "/", urlencode(params), "")) -def _strip_show_page_reauth_param(value: str) -> str: - target = _safe_remote_redirect_target(value) - parsed = urlsplit(target) - query = urlencode( - [ - (key, val) - for key, val in parse_qsl(parsed.query, keep_blank_values=True) - if key != REMOTE_SHOW_PAGE_REAUTH_PARAM - ] - ) - return urlunsplit(("", "", parsed.path or "/", query, "")) - - -def _add_show_page_reauth_param(value: str) -> str: - target = _strip_show_page_reauth_param(value) - parsed = urlsplit(target) - params = parse_qsl(parsed.query, keep_blank_values=True) - params.append((REMOTE_SHOW_PAGE_REAUTH_PARAM, "1")) - return urlunsplit(("", "", parsed.path or "/", urlencode(params), "")) - - def _oauth_callback_arg(name: str) -> str | None: return request.args.get(name) or request.args.get(f"amp;{name}") -def _show_page_id_from_private_route(path: str) -> str | None: - match = re.match(r"^/show/([^/]+)(?:/|$)", path or "") - if match is None: - return None - try: - from core.show_pages import validate_session_id - - return validate_session_id(unquote(match.group(1))) - except Exception: - return None - - def _redirect_to_vibe_cloud_login( config: V2Config, *, next_target: Any | None = None, - show_page_reauth: bool = False, ): from vibe import remote_access @@ -1833,10 +1798,7 @@ def _redirect_to_vibe_cloud_login( if next_target is not None else (request.full_path if request.query_string else request.path) ) - next_target = _strip_show_page_reauth_param(_strip_oauth_retry_param(raw_next)) - if show_page_reauth: - next_target = _add_show_page_reauth_param(next_target) - show_page_id = _show_page_id_from_private_route(urlsplit(next_target).path) + next_target = _strip_oauth_retry_param(raw_next) rid = secrets.token_urlsafe(18) state = _make_oauth_state( cloud.session_secret, @@ -1862,7 +1824,6 @@ def _redirect_to_vibe_cloud_login( next_target=next_target, device_hash=_oauth_device_hash(cloud.session_secret, device_id), redirect_uri=redirect_uri, - show_page_id=show_page_id, ) oauth_cookie = _make_oauth_cookie( cloud.session_secret, @@ -1872,7 +1833,6 @@ def _redirect_to_vibe_cloud_login( "code_verifier": code_verifier, "next": next_target, "redirect_uri": redirect_uri, - "show_page_id": show_page_id, "exp": int(datetime.now().timestamp()) + REMOTE_OAUTH_HANDSHAKE_TTL_SECONDS, }, ) @@ -1883,7 +1843,6 @@ def _redirect_to_vibe_cloud_login( nonce, code_challenge, redirect_uri=redirect_uri, - show_page_id=show_page_id, ) response.set_cookie( REMOTE_OAUTH_COOKIE_NAME, @@ -2417,25 +2376,6 @@ def enforce_remote_access_cookie(): payload = None if payload is not None: context = context_from_session_payload(payload) - if request.method == "GET" and context.instance_access_source != "show_page_email": - show_page_id = _show_page_id_from_private_route(request.path) - if show_page_id is not None: - resource_allowed = ( - context.can_use_show_page(show_page_id) - or _show_page_resource_access_allowed(context, show_page_id) - ) - reauth_attempted = request.args.get(REMOTE_SHOW_PAGE_REAUTH_PARAM) == "1" - wants_html = "text/html" in request.headers.get("Accept", "") - raw_next = request.full_path if request.query_string else request.path - if reauth_attempted and resource_allowed and wants_html: - return redirect(_strip_show_page_reauth_param(raw_next)) - if not resource_allowed: - if not wants_html: - return jsonify({"ok": False, "error": "remote_access_login_required"}), 401 - if not reauth_attempted: - if _auth_rate_limited(): - return _auth_rate_limit_response() - return _redirect_to_vibe_cloud_login(config, show_page_reauth=True) g.authorization_context = context g.remote_session_identity = identity g.remote_session_payload = payload @@ -2454,49 +2394,6 @@ def enforce_remote_access_cookie(): return jsonify({"ok": False, "error": "remote_access_login_required"}), 401 -@app.before_request -def enforce_show_page_email_scope(): - """Keep an email-grant session inside its one signed Show Page subtree.""" - - config = _load_remote_access_config() - if config is None or not _is_remote_access_request(config): - return None - context = getattr(g, "authorization_context", None) - if context is None: - from vibe.authorization import context_from_session_payload - - payload = _resolved_remote_session_payload(config) - if payload is not None: - context = context_from_session_payload(payload) - if context is None or context.instance_access_source != "show_page_email": - return None - - path = request.path or "" - public_static = ( - path.startswith("/assets/") - or path.startswith(f"{_SHOW_RUNTIME_VENDOR_PREFIX}/") - or path.startswith("/p/") - or path == "/favicon.ico" - or path in _PWA_PUBLIC_ASSETS - or path - in { - _SHOW_RUNTIME_PUBLIC_CLIENT_SHIM_PATH, - _SHOW_RUNTIME_PUBLIC_REACT_REFRESH_SHIM_PATH, - "/auth/callback", - "/auth/show-identity/callback", - "/auth/logout", - "/health", - } - ) - expected_prefix = f"/show/{context.show_page_id}" if context.show_page_id else "" - exact_show_page = bool( - expected_prefix and (path == expected_prefix or path.startswith(f"{expected_prefix}/")) - ) - if public_static or exact_show_page: - return None - return jsonify({"ok": False, "error": "show_page_access_forbidden"}), 403 - - def _request_authorization_context(context: Any = None): if context is not None: return context @@ -3656,16 +3553,10 @@ def _websocket_context_authorized( ) -> bool: if not context.has_role(minimum_role): return False - if context.instance_access_source == "show_page_email": - if project_session_id is None or not context.can_use_show_page(project_session_id): - return False - return True if project_session_id is None or _has_runtime_owner_access(context): return True if minimum_role == "viewer": - # §3.2: /show admission is the Instance Viewer role alone, independent of - # the Project ACL (which stays an edit/create requirement in ShowPageStore). - return context.has_role("viewer") and _show_page_resource_access_allowed(context, project_session_id) + return context.has_role("viewer") return _project_session_access_allowed(context, project_session_id, minimum_role) @@ -3674,14 +3565,10 @@ def _project_session_access_allowed(context: Any, session_id: str, minimum_role: if context is None: return False - if context.instance_access_source == "show_page_email": - return minimum_role == "viewer" and context.can_use_show_page(session_id) if _has_runtime_owner_access(context): return True if not context.has_role(minimum_role): return False - if minimum_role == "viewer" and context.can_use_show_page(session_id): - return True engine = _projects_engine() with engine.connect() as conn: role = project_access_service.get_effective_session_role( @@ -3702,11 +3589,7 @@ async def _wait_for_project_session_access_loss( event_type, _payload = await queue.get() if event_type != "authorization.changed": continue - if not _project_session_access_allowed( - context, - session_id, - minimum_role, - ) or not _show_page_resource_access_allowed(context, session_id): + if not _project_session_access_allowed(context, session_id, minimum_role): return @@ -3726,34 +3609,15 @@ async def _wait_for_show_page_access_loss( def _show_page_resource_access_allowed(context: Any, session_id: str) -> bool: - """§3.2 ``/show`` admission: Instance Viewer role alone, never an email grant. - - The Workbench is reachable by every Instance role (owner/editor/viewer) and - independent of the sharing list. A signed ``show_page_email`` session is a - ``/p``-only read visitor, so it never enters ``/show``. show_page has no - Resource ACL row anymore, so nothing is read from ``resource_access_service``. - ``session_id`` is retained for call-site symmetry but the decision does not - depend on it. - """ + """§3.2 ``/show`` admission: the Instance Viewer role alone.""" - if context is None: - return False - return context.has_role("viewer") and context.instance_access_source != "show_page_email" + return context is not None and context.has_role("viewer") def _show_page_mutation_allowed(context: Any) -> bool: - """§3.2 mutation boundary: only an Instance Editor/owner may drive ``/show``. + """§3.2 mutation boundary: only an Instance Editor/owner may drive ``/show``.""" - ``/show`` reads admit every Instance Viewer, but the route also forwards - POST/PUT/PATCH/DELETE to Show Runtime and exposes a live HMR websocket — - both mutation surfaces. Viewers stay read-only: mutations and HMR require - ``has_role("editor")``. A ``show_page_email`` session is Viewer-only, so it - can never pass an Editor check. - """ - - if context is None: - return False - return context.has_role("editor") and context.instance_access_source != "show_page_email" + return context is not None and context.has_role("editor") async def _wait_for_remote_session_authorization_loss( @@ -6945,7 +6809,6 @@ def remote_access_auth_callback(): handshake_nonce = cookie_state.get("nonce") next_target = cookie_state.get("next") redirect_uri = str(cookie_state.get("redirect_uri") or cloud.redirect_uri) - expected_show_page_id = cookie_state.get("show_page_id") elif store_record is not None and _oauth_store_record_device_bound(cloud.session_secret, store_record): # Store-fallback for the iOS standalone PWA case, where the handshake cookie's # state desyncs (authorize ran in a separate in-app-browser context). Gated on @@ -6958,7 +6821,6 @@ def remote_access_auth_callback(): handshake_nonce = store_record.get("nonce") next_target = store_record.get("next") redirect_uri = str(store_record.get("redirect_uri") or cloud.redirect_uri) - expected_show_page_id = store_record.get("show_page_id") else: # Neither the cookie nor the server-side store yielded the handshake. # Rate-limited: this branch is unauthenticated-reachable. @@ -6988,16 +6850,6 @@ def remote_access_auth_callback(): session_claims = result.get("session_claims") if not isinstance(session_claims, dict): raise remote_access.OAuthCodeExchangeError("invalid_session_claims") - if "vibe_show_page_id" in session_claims and ( - not isinstance(expected_show_page_id, str) - or session_claims.get("vibe_show_page_id") != expected_show_page_id - ): - raise remote_access.OAuthCodeExchangeError("invalid_show_page_id") - if ( - isinstance(expected_show_page_id, str) - and session_claims.get("vibe_show_page_id") == expected_show_page_id - ): - next_target = _strip_show_page_reauth_param(next_target) except Exception as exc: # Unauthenticated-reachable (valid handshake + bad code), so rate-limited. _log_oauth_callback_failure("code_exchange", exc) @@ -13297,14 +13149,11 @@ def _limited_show_access_grant_is_current(access: Any, grant: Any) -> bool: def _show_limited_viewer_is_allowed( context: Any, access: Any, - page_id: str, - *, - allow_page_scoped: bool = True, ) -> bool: allowlisted = _limited_show_access_admits( access, _show_access_visitor_from_context(context) ) - return allowlisted or (allow_page_scoped and context.can_use_show_page(page_id)) + return allowlisted async def _show_public_request_author() -> dict[str, str] | None: @@ -15305,14 +15154,9 @@ async def serve_public_show_page(share_id, asset_path): if not _show_limited_viewer_is_allowed( authenticated_context, access, - page.session_id, - allow_page_scoped=False, ): return _show_page_access_denied_response( - include_back_link=( - authenticated_context.instance_access_source - != "show_page_email" - ) + include_back_link=True ) # The current identity may be allowed again, but the # old lease must not be treated as valid guest access. @@ -15334,12 +15178,9 @@ async def serve_public_show_page(share_id, asset_path): if not _show_limited_viewer_is_allowed( authenticated_context, access, - page.session_id, ): return _show_page_access_denied_response( - include_back_link=( - authenticated_context.instance_access_source != "show_page_email" - ) + include_back_link=True ) if config is None: return _show_identity_error_response("identity_unavailable", 503)