Skip to content

Extract auth into enlace_auth (BREAKING; 0.1.0)#13

Merged
thorwhalen merged 1 commit intomainfrom
auth-extraction
Apr 28, 2026
Merged

Extract auth into enlace_auth (BREAKING; 0.1.0)#13
thorwhalen merged 1 commit intomainfrom
auth-extraction

Conversation

@thorwhalen
Copy link
Copy Markdown
Member

Summary

  • Extracts enlace.auth, enlace.stores, auth diagnostics, the auth env-var doctor checks, and the auth-* CLI commands out of enlace into a new sibling package, i2mint/enlace_auth (separate PR / repo).
  • enlace keeps a generic plugins=[] parameter on build_backend(config, *, plugins=()). Hosts opt in to auth by passing enlace_auth.plugin (or by setting ENLACE_PLUGINS=enlace_auth:plugin for the bundled create_app factory).
  • Bumps enlace to 0.1.0 (breaking).

Why split — enlace's mission is composition + discovery + routing. Auth had grown to ~1300 LOC across two subpackages plus diagnostics, env-var checks, and CLI commands; the planned admin dashboard would have piled another ~150 LOC on top. Hosts that don't want auth shouldn't carry it.

Public API breaks

Before After
from enlace import AuthConfig, OAuthProviderConfig, StoreBackendConfig from enlace_auth.config import …
PlatformConfig.auth: AuthConfig PlatformConfig.auth: dict[str, Any]
PlatformConfig.stores: dict[str, StoreBackendConfig] PlatformConfig.stores: dict[str, dict]
AppConfig.access: AccessLevel (Literal[…]) AppConfig.access: str (free-form; enlace ignores)
build_backend(config) auto-wires auth from [auth] config build_backend(config, plugins=[enlace_auth.plugin])
enlace.EnlaceConfigError for bad signing key enlace_auth.EnlaceAuthConfigError
enlace auth-init / auth-generate-signing-key / auth-hash-password / auth-list-sessions / auth-revoke-session enlace-auth init / generate-signing-key / hash-password / list-sessions / revoke-session
enlace.doctor._check_signing_key etc. enlace_auth.diagnostics.check_signing_key etc.; pass via run_doctor(extra_static_checks=…, extra_http_checks=…)

AppConfig.access, AppConfig.allowed_users, and AppConfig.shared_password_env stay on AppConfigenlace doesn't interpret them, but they round-trip through platform.toml and app.toml so enlace_auth can read them. No changes required to existing TOML files.

What stays in enlace

  • enlace.diagnose keeps the app-source-pattern scans (sub-app auth middleware, unsafe store keys, hardcoded user IDs). Those are app-side anti-patterns — they're useful regardless of which auth provider you use.
  • The EnlaceConfigError class itself (still raised for non-auth misconfig).

Migration for hosts

# Before
from enlace import build_backend, PlatformConfig
config = PlatformConfig.from_toml()
app = build_backend(config)

# After
from enlace import build_backend, PlatformConfig
from enlace_auth import plugin as auth_plugin
config = PlatformConfig.from_toml()
app = build_backend(config, plugins=[auth_plugin])

Or, if you serve via the bundled enlace.compose:create_app factory:

export ENLACE_PLUGINS=enlace_auth:plugin

Test plan

  • pytest enlace/tests tests — 78/78 pass on auth-extraction
  • pytest tests in enlace_auth — 98/98 pass (covers all moved tests + new test_admin.py)
  • tw_platform integration: bump enlace and add enlace_auth to requirements, set ENLACE_PLUGINS, redeploy through python deploy.py deploy (separate change-window; coordinated with the operator-side agent)

@thorwhalen thorwhalen force-pushed the auth-extraction branch 2 times, most recently from 8bd9162 to ce1801d Compare April 28, 2026 10:14
Copy link
Copy Markdown
Member Author

@thorwhalen thorwhalen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM with three minor follow-ups (none blocking — happy to land as-is). Posting as a comment-review before approval

Overall assessment

The split is exactly what was discussed in the handoff: `build_backend(config, *, plugins=())` is a clean keyword-only entry, the `Plugin` callable signature matches what `enlace_auth.plugin` advertises, and the `ENLACE_PLUGINS=mod:attr,...` env-var resolver in `create_app()` is well-bounded (raises `EnlaceConfigError` on parse / import / lookup / non-callable failures, distinct from any auth-specific error).

Removed surface in `enlace.auth` and `enlace.stores` is clean — all the `removed` files in the diff confirm both subpackages are gone, not just emptied. CI green on 3.10 and 3.12.

Things I checked closely

  • Plugin invocation order in `build_backend`: plugins run after the parent app is created and CORS middleware is added, but before `/_apps` is registered. That's the right order — `/_apps` reads `request.state.user_id` which the auth plugin's middleware sets, so the route definition has to come after the plugin mounts the middleware. ✓
  • ENLACE_PLUGINS resolver: handles each failure mode distinctly (no colon → format error; ImportError → import error; AttributeError → name error; non-callable → type error). All raise `EnlaceConfigError` so callers can catch one class. ✓
  • `AppConfig` round-trip fields (`access`, `allowed_users`, `shared_password_env`) all kept; `access` correctly relaxed from `Literal[...]` to plain `str`. Existing `app.toml` files in tw_platform and the in-progress papp PR (per-app policies) round-trip without edits. ✓
  • `PlatformConfig.auth` and `.stores` as untyped dicts — exactly what we agreed. tw_platform's `platform.toml` parses unchanged (verified: `coerce_auth_config(config.auth).enabled` is the new path; will reference in tw_platform docs). ✓
  • `run_doctor(extra_static_checks=, extra_http_checks=)` plugin hooks present and wired through. ✓
  • `doctor` in CLI keeps app-source-pattern scans (those are app-side anti-patterns, not auth-state checks — right call to keep them in enlace). ✓

Three minor follow-ups (not blocking)

  1. `enlace/init.py` says `version = "0.0.1"` but `pyproject.toml` says `0.1.0`. They'll desync after publish. Worth either bumping the `version` literal to `"0.1.0"` or, better, deriving it from package metadata (`importlib.metadata.version("enlace")`) so future bumps stay in one place.

  2. `compose._can_access` interprets the access strings (`"public"`, `"local"`, `"protected:user"`, `"protected:shared"`) for the `/_apps` listing filter. That's defensible — `/_apps` is part of enlace's discovery surface, and a UX filter isn't an auth gate — but the module docstring at the top of `base.py` says enlace is "auth-agnostic" and "does not interpret" `access`. There's a small contradiction between those two statements. Consider tightening the wording, e.g., "enlace doesn't enforce access — that's enlace_auth's job — but it does filter `/_apps` listings by the same string vocabulary, so the access strings are part of enlace's contract too."

  3. `Plugin` is a top-level type alias in `compose.py` but not re-exported from `enlace/init.py`. Plugin authors writing `from enlace import Plugin` will hit ImportError. Cheap to add to the re-export list.

None of these block. They can land in a follow-up PR after the user merges.

What I'm doing locally

Already opened companion PRs that depend on this landing:

  • thorwhalen/papp#1 — per-app `access` policies in `app.toml` files, recovered from the discarded server-side history.
  • thorwhalen/tw_platform#2 — `.env.example` + SYNC.md + twp-sync skill updated for `ENLACE_PLUGINS=enlace_auth:plugin`, `ENLACE_ADMIN_EMAILS`, and the `enlace-auth` CLI rename.

Both are safe to land after `enlace 0.1.0` and `enlace_auth 0.1.0` are published to PyPI but before the actual prod deploy. Coordinated deploy comes after.

The auth subsystem (auth router, sessions, CSRF, OAuth, per-app access
rules, the per-user store, and all related diagnostics) moves to a new
sibling package, i2mint/enlace_auth. Hosts opt in by passing the auth
plugin to build_backend or by setting ENLACE_PLUGINS=enlace_auth:plugin.

Why split:
- enlace's mission is composition + discovery + routing. Auth has grown
  to ~1300 LOC across two subpackages plus diagnostics, env-var checks,
  and CLI commands. Hosts that don't want auth shouldn't carry it.
- The admin dashboard work coming next would have piled another ~150
  LOC of router + UI plumbing into the same tree. Better to give
  auth+admin its own package now.

Public API breaks:
- AuthConfig, OAuthProviderConfig, StoreBackendConfig, AccessLevel
  removed from enlace.base. Now in enlace_auth.config.
- PlatformConfig.auth: dict[str, Any] (was AuthConfig).
- PlatformConfig.stores: dict[str, dict] (was dict[str, StoreBackendConfig]).
- AppConfig.access: free-form str (was AccessLevel Literal). Values
  parse identically; enlace just stops interpreting them.
- build_backend(config) no longer wires auth. Use plugins=[enlace_auth.plugin]
  or set ENLACE_PLUGINS=enlace_auth:plugin.
- enlace.compose.EnlaceConfigError no longer raised for bad signing keys —
  enlace_auth raises EnlaceAuthConfigError now.
- CLI auth-* commands removed from `enlace`. Re-added in enlace_auth as
  `enlace-auth init|generate-signing-key|hash-password|list-sessions|
  revoke-session`.
- enlace.doctor checks for signing-key / shared-passwords / oauth /
  csrf moved to enlace_auth.diagnostics. run_doctor gained
  extra_static_checks and extra_http_checks for plugins.

What stays in enlace:
- AppConfig fields access, allowed_users, shared_password_env (no-op
  fields enlace_auth reads).
- enlace.diagnose's app-source-pattern checks (sub-app auth middleware,
  unsafe store keys, etc.) — those are app-side, not platform-side.

Tests: enlace 78/78, enlace_auth 98/98 green.
Version: 0.1.0 (breaking).
@thorwhalen thorwhalen merged commit 780d776 into main Apr 28, 2026
12 checks passed
thorwhalen added a commit to i2mint/enlace_auth that referenced this pull request Apr 28, 2026
* ci: adopt wads uv-based workflow + bump to 0.1.0

- Add .github/workflows/ci.yml from wads/data/github_ci_uv.yml.
  Reads [tool.wads.ci] from pyproject (uv installer, 3.10/3.12
  matrix, no coverage, no Windows). Publishes to PyPI on main
  pushes via uv publish + the existing PYPI_PASSWORD token secret.

- Replace .gitignore with the wads template (only adds the standard
  wads_configs.json prefix; rest is identical to what was here).

- Bump version 0.0.1 -> 0.1.0 to match the public API contract
  documented in i2mint/enlace#13. The PR description says 0.1.0;
  the file said 0.0.1. Aligning before first publish so the wheel
  metadata matches what callers were told to pin against.

Pushing to a feature branch first to verify CI passes; merge to
main triggers publish.

* fix(lint): clear ruff failures (autofix + 2 line-length splits)

- ruff --fix: removed unused imports (Optional, AuthConfig) and
  reordered import blocks in __main__.py and diagnostics.py.
- admin/routes.py: split a long line by lifting a set comprehension
  into a named local (existing_lower).
- plugin.py: hoist getattr(app, 'shared_password_env', None) into a
  local so the if-test fits in 88 chars.

98/98 tests pass locally; ruff check clean.
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.

1 participant