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
44 changes: 15 additions & 29 deletions enlace_auth/auth/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
13 changes: 10 additions & 3 deletions enlace_auth/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
)


Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
128 changes: 128 additions & 0 deletions tests/test_launcher_gate_agree.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""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"
)
# Mixed case on purpose: both sides must compare case-insensitively.
(apps_dir / "gated" / "app.toml").write_text(
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(
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, app="gated") -> bool:
status = client.get(f"/api/{app}/x").status_code
assert status in (200, 401), status
return status == 200


def _listed(client, app="gated") -> bool:
return app 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.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):
"""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
Loading