Skip to content

Launcher and auth gate share one visibility predicate, so runtime grants show in /_apps - #56

Merged
thorwhalen merged 2 commits into
mainfrom
fix/launcher-honours-grants
Sep 25, 2026
Merged

thorwhalen merged 2 commits into
mainfrom
fix/launcher-honours-grants

Conversation

@thorwhalen

Copy link
Copy Markdown
Member

Closes #35.

What was wrong

Two code paths answered "may this user reach this app": enlace_auth's request-time gate (static allowed_users ∪ live runtime grants, case-insensitive) and the /_apps launcher here (static list only, case-sensitive). A user with a runtime grant could open an app but never see it in the launcher.

The fix: one predicate, called by both sides

  • New enlace/access.py: is_user_allowed (the protected:user allowlist predicate), can_see_app (launcher visibility by access level), granted_users (per-request grants resolution, fail-closed on resolver errors), and GRANTS_STATE_ATTR (dynamic_allowed_users), the root-app state slot where the auth plugin puts the same grants resolver its gate uses.
  • /_apps, /_apps/{name}/icon (and the other icon/manifest routes behind _visible_app) and the built-in HTML index all go through _can_access, which delegates to enlace.access. The built-in index used to list every app to everyone; it now lists only what the caller may open.
  • Without an auth plugin (no resolver), behaviour is unchanged except that static allowed_users matching is now case-insensitive, like the gate.

The gate side lands in i2mint/enlace_auth (a PR follows once this release is on PyPI): its middleware calls enlace.access.is_user_allowed + granted_users, and its plugin hands the same resolver to both sides. That repo also carries the end-to-end test that runs the real gate and the real launcher side by side and asserts opens ⟺ listed for static-only, grant-only, both, neither, mixed case and open apps, plus grant and expiry without a restart.

Review

An independent adversarial review compared old and new gate behaviour line by line. It found no blockers. What it raised, and what was done:

  • Logs. The launcher resolves grants for each protected app on every call, so a broken grants store would log one traceback per app per call. Now it logs one line per failure, with the old message text, and the traceback goes to DEBUG. The logger name changes from enlace_auth.middleware to enlace.access.
  • Sessions with no user_id. The gate now refuses a session whose user_id is missing. Before, it could let one into an open app. Every session creator sets user_id, so only a malformed record is affected. This is the stricter direction.
  • Cost. About 7 ms per /_apps call with 30 apps on the file-backed grant store. That was judged acceptable. Caching the grants would bring back the expiry drift that A runtime grant lets a user open a protected app but not see it in the launcher #35 warns about.

Release ordering

Merging releases enlace 0.1.41. enlace_auth will require enlace>=0.1.41. New enlace with the old enlace_auth is safe: no resolver is injected, so the launcher uses the static list only.

https://claude.ai/code/session_01JQJh4hGdYjSKiFkyj9zrAh

…nts show in /_apps

The /_apps launcher and enlace_auth's request-time gate each computed
"may this user reach this app" separately, and drifted: the gate unioned
static allowed_users with live runtime grants (case-insensitively), the
launcher did neither. A granted user could open an app they could never find.

New enlace.access module holds the single copy: is_user_allowed (the
protected:user allowlist predicate), can_see_app (launcher visibility by
access level), and granted_users (per-request grants resolution, fail-closed
on resolver errors). The launcher and its icon route read the grants resolver
from the root app's state (GRANTS_STATE_ATTR = dynamic_allowed_users), which
the auth plugin installs; absent it, behaviour is unchanged.

Refs #35 (the gate side lands in enlace_auth, calling the same predicate).
… log

The built-in HTML index (used when there is no landing app) listed every app
to every caller; it now shows only what the caller may open, via the same
_can_access as /_apps.

granted_users logs one line per failure (traceback at DEBUG) with the text the
gate used before, since the launcher resolves every protected app per call.
@thorwhalen
thorwhalen merged commit 5bf1866 into main Sep 25, 2026
12 checks passed
@thorwhalen
thorwhalen deleted the fix/launcher-honours-grants branch September 25, 2026 10:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A runtime grant lets a user open a protected app but not see it in the launcher

1 participant