From 702bdc849a8b3178c346d2597318b55369849807 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:07:31 +0530 Subject: [PATCH 1/2] Gate and /_apps launcher call one predicate, so a runtime grant shows in the launcher The request-time gate unioned static allowed_users with live runtime grants; enlace core's launcher read only the static list. A granted user could open an app but never see it (i2mint/enlace#35). The gate's allowlist check now calls enlace.access.is_user_allowed, the same predicate the launcher calls, with grants resolved by enlace.access.granted_users (per request, fail-closed on store errors). The plugin builds the grants resolver once and hands it to both the gate and enlace core (via the dynamic_allowed_users state slot). New tests run the real gate and the real launcher side by side and assert opens <=> listed for static-only, grant-only, both and neither users, and that granting and expiry reach both sides without a restart. Requires enlace>=0.1.41. --- enlace_auth/auth/middleware.py | 44 ++++------- enlace_auth/plugin.py | 13 +++- pyproject.toml | 2 +- tests/test_launcher_gate_agree.py | 117 ++++++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 33 deletions(-) create mode 100644 tests/test_launcher_gate_agree.py diff --git a/enlace_auth/auth/middleware.py b/enlace_auth/auth/middleware.py index 97aa2cd..e63c2a2 100644 --- a/enlace_auth/auth/middleware.py +++ b/enlace_auth/auth/middleware.py @@ -35,6 +35,8 @@ from typing import Callable, Iterable, Optional from urllib.parse import unquote +from enlace.access import granted_users, is_user_allowed + from enlace_auth.auth.cookies import verify_cookie from enlace_auth.auth.revocation import shared_cookie_valid from enlace_auth.auth.sessions import SessionStore @@ -325,35 +327,19 @@ async def __call__(self, scope, receive, send): state["user_id"] = session.get("user_id") state["user_email"] = session.get("email") # Per-app user whitelist = static config ``allowed_users`` ∪ active - # runtime grants (the dynamic resolver, when wired). Gate ONLY when - # the resulting set is non-empty, so an app with neither stays open - # to any authenticated user (preserving the empty-allowed_users - # "open" semantic). Both sides are lowercased to avoid case- - # sensitivity surprises. - config_allowed = ( - {e.lower() for e in rule.allowed_users} if rule is not None else set() - ) - dynamic_allowed: set[str] = set() - if self._dynamic is not None and rule is not None: - try: - dynamic_allowed = { - e.lower() for e in (self._dynamic(rule.app_id) or set()) - } - except Exception: # noqa: BLE001 - # A grants-store hiccup must never break auth. Fail closed - # for grant-based access (config users still work). - _logger.warning( - "dynamic grants lookup failed for app_id=%r; " - "falling back to config allowed_users only", - rule.app_id, - exc_info=True, - ) - dynamic_allowed = set() - allowed = config_allowed | dynamic_allowed - if allowed: - who = (state.get("user_email") or state.get("user_id") or "").lower() - if who not in allowed: - return await self._deny(scope, send, "forbidden") + # runtime grants (the dynamic resolver, when wired), decided by + # enlace core's ``is_user_allowed`` — the SAME predicate the /_apps + # launcher calls, so a user sees in the launcher exactly the apps + # this gate lets them open (enlace issue #35). ``granted_users`` + # resolves grants live, per request, and fails closed on a + # grants-store error (config users still work). + if rule is not None and not is_user_allowed( + state.get("user_id"), + state.get("user_email"), + allowed_users=rule.allowed_users, + granted=granted_users(self._dynamic, rule.app_id), + ): + return await self._deny(scope, send, "forbidden") await self.app(scope, receive, send) return diff --git a/enlace_auth/plugin.py b/enlace_auth/plugin.py index f148c1a..1ed0f8f 100644 --- a/enlace_auth/plugin.py +++ b/enlace_auth/plugin.py @@ -29,6 +29,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Callable, Iterable, Optional +from enlace.access import GRANTS_STATE_ATTR + from enlace_auth.config import coerce_auth_config, coerce_stores_map if TYPE_CHECKING: @@ -345,6 +347,10 @@ def wire(parent: "FastAPI", config) -> None: grants_root = Path(os.path.expanduser(auth_cfg.stores.path)) / "grants" grant_store = GrantStore(platform_factory("grants"), root=grants_root) + def grants_resolver(app_id: str) -> set[str]: + """Emails with an active grant for ``app_id``; ``now`` read per call.""" + return grant_store.active_emails_for_app(app_id, now=time.time()) + stores_map = coerce_stores_map(getattr(config, "stores", None)) user_data_cfg = stores_map.get("user_data") user_data_backend: Optional[object] = None @@ -572,6 +578,9 @@ def wire(parent: "FastAPI", config) -> None: # DI slots read by enlace core (compose._overlay_entry / apps_listing). parent.state.app_meta_overlay = overlay_store parent.state.app_meta_can_edit = can_edit_meta + # The grants resolver the gate consults, handed to enlace core too, so the + # /_apps launcher shows a granted user the apps they can open (enlace #35). + setattr(parent.state, GRANTS_STATE_ATTR, grants_resolver) parent.include_router( make_appmeta_router( @@ -643,9 +652,7 @@ def wire(parent: "FastAPI", config) -> None: login_redirect_path="/auth/login", # Consult runtime grants live, per request, on top of each rule's static # allowed_users. ``now`` is evaluated at call time so expiry is exact. - dynamic_allowed_users=lambda app_id: grant_store.active_emails_for_app( - app_id, now=time.time() - ), + dynamic_allowed_users=grants_resolver, ) diff --git a/pyproject.toml b/pyproject.toml index 31c0999..9580e42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ requires-python = ">=3.10" keywords = ["enlace", "auth", "fastapi", "platform", "argon2", "session", "admin"] authors = [{ name = "Thor Whalen" }] dependencies = [ - "enlace>=0.1.25", # needs build_launcher_item + app_meta DI slots + "enlace>=0.1.41", # needs enlace.access (shared gate/launcher predicate) "fastapi>=0.100.0", "pydantic>=2.0.0", "itsdangerous>=2.1", diff --git a/tests/test_launcher_gate_agree.py b/tests/test_launcher_gate_agree.py new file mode 100644 index 0000000..b6f8e2a --- /dev/null +++ b/tests/test_launcher_gate_agree.py @@ -0,0 +1,117 @@ +"""The gate and the ``/_apps`` launcher agree on who may reach an app (enlace #35). + +A runtime grant used to let a user OPEN a ``protected:user`` app without ever +SEEING it in the launcher: the gate unioned static ``allowed_users`` with live +grants, the launcher read only the static list. Both now call enlace core's +``enlace.access.is_user_allowed``, fed by the one grants resolver this plugin +builds. + +These tests run the real gate and the real launcher side by side, so they fail +if either side changes alone. They assert the equivalence — opens ⟺ listed — +for static-only, grant-only, both and neither, and not merely "a granted user +sees the app", which a launcher hard-wired to ``True`` would pass. +""" + +from __future__ import annotations + +import time +from pathlib import Path + +import pytest +from enlace.base import PlatformConfig +from enlace.compose import build_backend +from enlace.discover import discover_apps +from starlette.testclient import TestClient + +from enlace_auth import plugin as auth_plugin +from enlace_auth.auth import GrantStore +from enlace_auth.stores import make_file_store_factory +from tests.test_admin import _SIGNING_KEY, _csrf, _register + +STATIC = "static@example.com" +GRANTED = "granted@example.com" +BOTH = "both@example.com" +NEITHER = "neither@example.com" + + +def _platform(tmp_path, monkeypatch): + apps_dir = tmp_path / "apps" + (apps_dir / "ping").mkdir(parents=True) + (apps_dir / "ping" / "server.py").write_text( + "from fastapi import FastAPI\napp = FastAPI()\n" + "@app.get('/ping')\ndef ping():\n return {'ok': True}\n" + ) + (apps_dir / "gated").mkdir() + (apps_dir / "gated" / "server.py").write_text( + "from fastapi import FastAPI\napp = FastAPI()\n" + "@app.get('/x')\ndef x():\n return {'ok': True}\n" + ) + (apps_dir / "gated" / "app.toml").write_text( + f'access = "protected:user"\nallowed_users = ["{STATIC}", "{BOTH}"]\n' + ) + monkeypatch.setenv("ENLACE_SIGNING_KEY", _SIGNING_KEY) + platform_store = tmp_path / "platform" + config = PlatformConfig( + apps_dir=apps_dir, + auth={ + "enabled": True, + "secure_cookies": False, + "registration_open": True, + "stores": {"backend": "file", "path": str(platform_store)}, + }, + stores={"user_data": {"backend": "file", "path": str(tmp_path / "data")}}, + ) + backend = build_backend(discover_apps(config), plugins=[auth_plugin]) + grants = GrantStore( + make_file_store_factory(str(platform_store))("grants"), + root=Path(platform_store) / "grants", + ) + return backend, grants + + +def _signed_in(backend, email): + client = TestClient(backend) + assert _register(client, email, "password-123", _csrf(client)).status_code == 200 + return client + + +def _opens(client) -> bool: + status = client.get("/api/gated/x").status_code + assert status in (200, 401), status + return status == 200 + + +def _listed(client) -> bool: + return "gated" in {a["name"] for a in client.get("/_apps").json()["apps"]} + + +@pytest.mark.parametrize( + "email, expected", + [(STATIC, True), (GRANTED, True), (BOTH, True), (NEITHER, False)], +) +def test_opens_iff_listed(tmp_path, monkeypatch, email, expected): + """For each kind of user, the launcher and the gate return the same verdict.""" + backend, grants = _platform(tmp_path, monkeypatch) + grants.grant("gated", GRANTED) + grants.grant("gated", BOTH) + client = _signed_in(backend, email) + assert _opens(client) is _listed(client) is expected + + +def test_anonymous_neither_opens_nor_sees(tmp_path, monkeypatch): + backend, _ = _platform(tmp_path, monkeypatch) + client = TestClient(backend) + assert _opens(client) is _listed(client) is False + + +def test_grant_and_expiry_reach_both_sides_without_restart(tmp_path, monkeypatch): + """Granted at runtime → opens AND listed; expired → neither. No restart.""" + backend, grants = _platform(tmp_path, monkeypatch) + client = _signed_in(backend, GRANTED) + assert _opens(client) is _listed(client) is False + + grants.grant("gated", GRANTED, expires_at=time.time() + 3600) + assert _opens(client) is _listed(client) is True + + grants.grant("gated", GRANTED, expires_at=time.time() - 1) + assert _opens(client) is _listed(client) is False From 397a4cb9720cc180bc63af2ab0091df271ce37a0 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:15:30 +0530 Subject: [PATCH 2/2] Cover mixed-case allowlists/grants and open apps in the gate/launcher agreement test --- tests/test_launcher_gate_agree.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/tests/test_launcher_gate_agree.py b/tests/test_launcher_gate_agree.py index b6f8e2a..6885723 100644 --- a/tests/test_launcher_gate_agree.py +++ b/tests/test_launcher_gate_agree.py @@ -46,9 +46,17 @@ def _platform(tmp_path, monkeypatch): "from fastapi import FastAPI\napp = FastAPI()\n" "@app.get('/x')\ndef x():\n return {'ok': True}\n" ) + # Mixed case on purpose: both sides must compare case-insensitively. (apps_dir / "gated" / "app.toml").write_text( - f'access = "protected:user"\nallowed_users = ["{STATIC}", "{BOTH}"]\n' + f'access = "protected:user"\nallowed_users = ["{STATIC.upper()}", "{BOTH}"]\n' ) + # No allowlist and no grants: open to any signed-in user, on both sides. + (apps_dir / "open_app").mkdir() + (apps_dir / "open_app" / "server.py").write_text( + "from fastapi import FastAPI\napp = FastAPI()\n" + "@app.get('/x')\ndef x():\n return {'ok': True}\n" + ) + (apps_dir / "open_app" / "app.toml").write_text('access = "protected:user"\n') monkeypatch.setenv("ENLACE_SIGNING_KEY", _SIGNING_KEY) platform_store = tmp_path / "platform" config = PlatformConfig( @@ -75,14 +83,14 @@ def _signed_in(backend, email): return client -def _opens(client) -> bool: - status = client.get("/api/gated/x").status_code +def _opens(client, app="gated") -> bool: + status = client.get(f"/api/{app}/x").status_code assert status in (200, 401), status return status == 200 -def _listed(client) -> bool: - return "gated" in {a["name"] for a in client.get("/_apps").json()["apps"]} +def _listed(client, app="gated") -> bool: + return app in {a["name"] for a in client.get("/_apps").json()["apps"]} @pytest.mark.parametrize( @@ -92,16 +100,19 @@ def _listed(client) -> bool: def test_opens_iff_listed(tmp_path, monkeypatch, email, expected): """For each kind of user, the launcher and the gate return the same verdict.""" backend, grants = _platform(tmp_path, monkeypatch) - grants.grant("gated", GRANTED) + grants.grant("gated", GRANTED.upper()) grants.grant("gated", BOTH) client = _signed_in(backend, email) assert _opens(client) is _listed(client) is expected + # The open app admits, and lists, every signed-in user. + assert _opens(client, "open_app") is _listed(client, "open_app") is True def test_anonymous_neither_opens_nor_sees(tmp_path, monkeypatch): backend, _ = _platform(tmp_path, monkeypatch) client = TestClient(backend) assert _opens(client) is _listed(client) is False + assert _opens(client, "open_app") is _listed(client, "open_app") is False def test_grant_and_expiry_reach_both_sides_without_restart(tmp_path, monkeypatch):