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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ SM_DATABASE_URL=sqlite+aiosqlite:///./app.db
SM_ENVIRONMENT=development
SM_SECRET_KEY=change-me-in-production

# Auth provider: `users` (local accounts, the default) or `keycloak` (OIDC).
# Only one can be active at a time — they claim the same auth slot. A dev
# workspace has both installed because `uv sync --all-packages` installs every
# member, so the non-selected one is skipped at discovery instead of failing
# the boot with SM020. Ignored when only one provider is installed.
# SM_AUTH_PROVIDER=keycloak

# Dev-only: Vite asset URL (ignored in production builds)
SM_VITE_DEV_URL=http://localhost:5050

Expand Down
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ Local deployments only need one env var — everything else has sensible default
| `SM_ENVIRONMENT` | `development` | No — any value other than `development`, `test`, `testing` triggers strict discovery and placeholder-secret checks |
| `SM_SECRET_KEY` | `change-me-in-production` | No in dev; **must** be overridden in production |
| `SM_VITE_DEV_URL` | `http://localhost:5050` | Dev only — Vite HMR origin |
| `SM_AUTH_PROVIDER` | `users` | No — `users` or `keycloak`. Only read when both are installed; see [Auth providers](#auth-providers-users-or-keycloak) |

Power users can still override the following bootstrap knobs via env if needed: `SM_DB_POOL_SIZE`, `SM_DB_MAX_OVERFLOW`, `SM_DB_POOL_PRE_PING`, `SM_DB_POOL_RECYCLE`, `SM_DEBUG`, `SM_LOG_LEVEL`, `SM_LOG_FORMAT`, `SM_MODULES_ENABLED`. These are needed before the DB connection is open.

Expand Down Expand Up @@ -160,6 +161,47 @@ The 300-line file cap (enforced by CI) usually pushes you to factor row-level co

## User management

### Auth providers: users **or** keycloak, never both

This repo ships two authentication providers, and **exactly one can be active at a time**:

| Module | What it does |
|---|---|
| `users` (default) | Local accounts — password login, invites, signup, roles, an admin UI |
| `keycloak` | Delegates authentication to a Keycloak realm over OIDC |

Both claim the same slot (`app.state.auth.auth_provider`), so running them together
is a misconfiguration, not a supported combination. Installing both and activating
neither is reported as `SM020` (error — fails boot); installing neither is `SM021`.

`uv sync --all-packages` installs *every* workspace member, so a dev clone has both
packages on disk. The host therefore activates one and skips the other rather than
failing on `SM020`, and **`users` is the default** — `keycloak` is installed but inert
until you ask for it. To switch:

```bash
# .env
SM_AUTH_PROVIDER=keycloak
```

Then re-run `make gen-pages` so the frontend manifest picks up the active provider's
pages (`make dev` does this for you), and configure the realm under
`/settings/modules`. Switching back is the same knob set to `users`.

Two caveats when running Keycloak:

- `dashboard`, `permissions`, `audit_log`, and `background_tasks` declare a
dependency on the `Users` **module** and import from the `users` package, so
`simple_module_users` still has to be installed even though it is inactive. A
host that wants Keycloak *and* none of the local-account machinery should leave
those modules out of its own dependency list.
- The local-account flows (`/users/login`, invites, signup, the sections below)
belong to the `users` module and are gone while Keycloak is active — Keycloak's
realm owns login, logout, and user administration instead.

Only one provider is ever discovered, so nothing here changes if you install just one
of the two: a host that ships only `keycloak` keeps it regardless of `SM_AUTH_PROVIDER`.

### Creating the first admin

Either use the CLI:
Expand Down
6 changes: 6 additions & 0 deletions framework/core/simple_module_core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@
run_diagnostics,
)
from simple_module_core.discovery import (
DEFAULT_AUTH_PROVIDER,
discover_modules,
get_module_package_name,
resolve_auth_provider,
select_auth_provider,
topological_sort,
)
from simple_module_core.events import Event, EventBus
Expand Down Expand Up @@ -39,6 +42,7 @@
from simple_module_core.versioning import FRAMEWORK_API_VERSION, check_framework_compatibility

__all__ = [
"DEFAULT_AUTH_PROVIDER",
"FRAMEWORK_API_VERSION",
"CircularDependencyError",
"DesignPack",
Expand Down Expand Up @@ -77,6 +81,8 @@
"is_flag_enabled",
"print_diagnostics",
"require_flag",
"resolve_auth_provider",
"run_diagnostics",
"select_auth_provider",
"topological_sort",
]
11 changes: 10 additions & 1 deletion framework/core/simple_module_core/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@
print_diagnostics,
run_diagnostics,
)
from simple_module_core.discovery import discover_modules, topological_sort
from simple_module_core.discovery import (
discover_modules,
resolve_auth_provider,
select_auth_provider,
topological_sort,
)
from simple_module_core.dotenv import parse_dotenv
from simple_module_core.exceptions import InvalidModuleError

Expand Down Expand Up @@ -100,6 +105,10 @@ def main() -> int:
print("No modules discovered. Is the project installed (`uv sync --all-packages`)?")
return 0

# Mirror the host: only the configured auth provider is active, so doctor
# reports on the same module set the app actually boots with.
modules = select_auth_provider(modules, resolve_auth_provider())

# Topological sort surfaces CircularDependencyError early.
modules = topological_sort(modules)

Expand Down
65 changes: 65 additions & 0 deletions framework/core/simple_module_core/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@
from collections.abc import Sequence
from importlib.metadata import entry_points

from simple_module_core.dotenv import env_str, load_dotenv_into_environ
from simple_module_core.exceptions import CircularDependencyError, InvalidModuleError
from simple_module_core.module import ModuleBase, ModuleMeta

logger = logging.getLogger(__name__)

ENTRY_POINT_GROUP = "simple_module"

DEFAULT_AUTH_PROVIDER = "users"
"""Auth provider activated when several are installed — see select_auth_provider."""


def get_module_package_name(module: ModuleBase) -> str:
"""Return the top-level Python package a module instance belongs to.
Expand Down Expand Up @@ -121,6 +125,67 @@ def fail(msg: str, exc: BaseException | None = None) -> None:
return modules


def resolve_auth_provider() -> str:
"""Return the configured auth provider name, honoring ``.env``.

For tools that run outside the host process (``make doctor``, ``smpy host
gen-pages``) and so have no ``BootstrapSettings`` to read from. The host
passes ``settings.auth_provider`` to :func:`select_auth_provider` instead.
"""
load_dotenv_into_environ()
return env_str("SM_AUTH_PROVIDER", DEFAULT_AUTH_PROVIDER)


def select_auth_provider(
modules: Sequence[ModuleBase],
preferred: str = DEFAULT_AUTH_PROVIDER,
*,
strict: bool = False,
) -> list[ModuleBase]:
"""Drop every auth provider except ``preferred`` when several are installed.

``users`` and ``keycloak`` both claim ``app.state.auth.auth_provider``, so
only one can be active — that's what SM020 reports. A dev workspace has
both installed (``uv sync --all-packages`` installs every member), which
would otherwise fail the boot on a fresh clone. Selecting one here keeps
the alternative provider installed and testable but inert.

Nothing is dropped when fewer than two providers are present — a host that
installs only ``keycloak`` keeps it whatever ``preferred`` says.

A ``preferred`` that names none of the installed providers is a
misconfiguration (typically a typo). Leaving every provider mounted means
two modules write ``app.state.auth.auth_provider`` and the topological
order silently decides the winner, so this always warns, and with
``strict`` raises :class:`InvalidModuleError`. Callers in production
should pass ``strict=True``: SM020 would catch this, but diagnostics run
in development only, so nothing else reports it in a deployed app.
"""
providers = [m for m in modules if getattr(m, "_is_auth_provider", False)]
if len(providers) < 2:
return list(modules)

installed = ", ".join(m.meta.name for m in providers)
keep = next((m for m in providers if m.meta.name.lower() == preferred.lower()), None)
if keep is None:
msg = (
f"SM_AUTH_PROVIDER={preferred!r} matches none of the installed auth "
f"providers ({installed}); all of them stay mounted and the last one "
"registered wins"
)
if strict:
raise InvalidModuleError(msg)
logger.warning("%s — set it to one of the names above", msg)
return list(modules)

logger.info(
"Auth provider '%s' active (SM_AUTH_PROVIDER); skipping also-installed: %s",
keep.meta.name,
", ".join(m.meta.name for m in providers if m is not keep),
)
return [m for m in modules if m is keep or not getattr(m, "_is_auth_provider", False)]


def topological_sort(modules: Sequence[ModuleBase]) -> list[ModuleBase]:
"""Sort modules so dependencies come before dependents.

Expand Down
133 changes: 133 additions & 0 deletions framework/core/tests/test_auth_provider_selection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""Tests for select_auth_provider — picking one of several installed providers."""

from __future__ import annotations

import logging
import os

import pytest
from simple_module_core.discovery import (
DEFAULT_AUTH_PROVIDER,
resolve_auth_provider,
select_auth_provider,
)
from simple_module_core.exceptions import InvalidModuleError
from simple_module_core.module import ModuleBase, ModuleMeta


class FakeUsers(ModuleBase):
meta = ModuleMeta(name="Users")
_is_auth_provider = True


class FakeKeycloak(ModuleBase):
meta = ModuleMeta(name="Keycloak")
_is_auth_provider = True


class FakeDashboard(ModuleBase):
meta = ModuleMeta(name="Dashboard")


def _names(modules):
return [m.meta.name for m in modules]


class TestSelectAuthProvider:
def test_default_keeps_users_and_drops_keycloak(self):
"""Both installed (the dev workspace) → the default provider wins, no SM020."""
result = select_auth_provider([FakeUsers(), FakeKeycloak(), FakeDashboard()])
assert _names(result) == ["Users", "Dashboard"]

def test_preferred_keycloak_drops_users(self):
result = select_auth_provider([FakeUsers(), FakeKeycloak()], "keycloak")
assert _names(result) == ["Keycloak"]

def test_match_is_case_insensitive(self):
result = select_auth_provider([FakeUsers(), FakeKeycloak()], "KeyCloak")
assert _names(result) == ["Keycloak"]

def test_non_provider_modules_keep_their_order(self):
modules = [FakeDashboard(), FakeKeycloak(), FakeUsers()]
assert _names(select_auth_provider(modules)) == ["Dashboard", "Users"]

def test_lone_provider_survives_a_mismatched_preference(self):
"""A keycloak-only host keeps keycloak even at the default preference."""
result = select_auth_provider([FakeKeycloak(), FakeDashboard()], "users")
assert _names(result) == ["Keycloak", "Dashboard"]

def test_no_providers_is_untouched(self):
"""SM021 territory — nothing to select, and nothing to drop."""
assert _names(select_auth_provider([FakeDashboard()])) == ["Dashboard"]

def test_unknown_preference_keeps_every_provider(self):
"""Naming a provider that isn't installed must not silently pick one."""
result = select_auth_provider([FakeUsers(), FakeKeycloak()], "oidc")
assert _names(result) == ["Users", "Keycloak"]

def test_unknown_preference_warns(self, caplog):
"""SM020 only runs in development — a typo must not pass in silence."""
with caplog.at_level(logging.WARNING, logger="simple_module_core.discovery"):
select_auth_provider([FakeUsers(), FakeKeycloak()], "keycloack")
warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
assert warnings, "an unrecognised provider name logged nothing"
assert "keycloack" in warnings[0].getMessage()

def test_unknown_preference_raises_when_strict(self):
"""Production boots strict, where mounting both providers is not viable."""
with pytest.raises(InvalidModuleError, match="keycloack"):
select_auth_provider([FakeUsers(), FakeKeycloak()], "keycloack", strict=True)

def test_strict_is_quiet_on_a_valid_selection(self):
result = select_auth_provider([FakeUsers(), FakeKeycloak()], "users", strict=True)
assert _names(result) == ["Users"]

def test_strict_ignores_a_mismatch_when_only_one_is_installed(self):
"""Nothing is ambiguous with a single provider, so strict must not raise."""
result = select_auth_provider([FakeKeycloak()], "oidc", strict=True)
assert _names(result) == ["Keycloak"]

def test_selection_is_logged(self, caplog):
with caplog.at_level(logging.INFO, logger="simple_module_core.discovery"):
select_auth_provider([FakeUsers(), FakeKeycloak()])
assert any("Keycloak" in rec.getMessage() for rec in caplog.records)

def test_input_is_not_mutated(self):
modules = [FakeUsers(), FakeKeycloak()]
select_auth_provider(modules)
assert len(modules) == 2


class TestResolveAuthProvider:
@pytest.fixture(autouse=True)
def _isolated_environ(self, monkeypatch, tmp_path):
"""Swap in a throwaway ``os.environ``.

``resolve_auth_provider`` merges ``.env`` into the real environment via
``setdefault``, which ``monkeypatch.delenv(raising=False)`` can't undo —
it records nothing for a key that was already absent. Without this the
provider chosen here would leak into every later test in the session.
"""
monkeypatch.setattr(os, "environ", dict(os.environ))
monkeypatch.setenv("SM_PROJECT_ROOT", str(tmp_path))
monkeypatch.delenv("SM_AUTH_PROVIDER", raising=False)

def test_defaults_to_users(self):
assert resolve_auth_provider() == DEFAULT_AUTH_PROVIDER

def test_reads_the_env_var(self, monkeypatch):
monkeypatch.setenv("SM_AUTH_PROVIDER", "keycloak")
assert resolve_auth_provider() == "keycloak"

def test_blank_falls_back_to_the_default(self, monkeypatch):
monkeypatch.setenv("SM_AUTH_PROVIDER", " ")
assert resolve_auth_provider() == DEFAULT_AUTH_PROVIDER

def test_reads_dotenv_when_env_is_unset(self, tmp_path):
(tmp_path / ".env").write_text("SM_AUTH_PROVIDER=keycloak\n", encoding="utf-8")
assert resolve_auth_provider() == "keycloak"

def test_real_environment_wins_over_dotenv(self, monkeypatch, tmp_path):
(tmp_path / ".env").write_text("SM_AUTH_PROVIDER=keycloak\n", encoding="utf-8")
monkeypatch.setenv("SM_AUTH_PROVIDER", "users")
assert resolve_auth_provider() == "users"
9 changes: 8 additions & 1 deletion framework/hosting/simple_module_hosting/app_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from fastapi import FastAPI
from simple_module_core.design_packs import DesignPackRegistry
from simple_module_core.diagnostics import DiagnosticLevel, print_diagnostics, run_diagnostics
from simple_module_core.discovery import discover_modules, topological_sort
from simple_module_core.discovery import discover_modules, select_auth_provider, topological_sort
from simple_module_core.events import EventBus
from simple_module_core.feature_flags import FeatureFlagRegistry
from simple_module_core.health import HealthRegistry
Expand Down Expand Up @@ -120,6 +120,13 @@ def create_app(settings: Settings | None = None) -> FastAPI:
enabled=settings.modules_enabled,
strict=not settings.is_development,
)
# Two auth providers can be installed at once (they are in this workspace);
# only the configured one is activated. See select_auth_provider / SM020.
# Strict outside development: diagnostics don't run there, so an
# unrecognised name would otherwise mount both providers unreported.
modules = select_auth_provider(
modules, settings.auth_provider, strict=not settings.is_development
)
modules = topological_sort(modules)
logger.info(
"Loaded %d module(s): %s",
Expand Down
Loading