mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-26 16:07:53 +00:00
* fix(auth): let deployments close local self-registration The OIDC provisioning policy (allowed_email_domains, require_verified_email, auto_create_users) is enforced only in the SSO callback via get_or_provision_oidc_user. POST /api/v1/auth/register creates a local account without consulting any of it, and nothing can turn that path off, so a deployment declaring an email-domain allowlist can still be joined by any address through local registration. Add auth.local.allow_registration (default true, so existing deployments are unchanged) and gate /register on it before the account is created. Report the flag from /setup-status so the login page stops offering a signup entry the Gateway will reject. /initialize is deliberately not gated: it is the bootstrap path, guarded by admin_count == 0, and closing it would leave a fresh install unable to create its first admin. An unreadable config.yaml falls back to the pre-gate default (open) rather than making these two endpoints a hard dependency on the file. * docs(auth): align registration-gate fallback wording with the FileNotFoundError catch
167 lines
6.6 KiB
Python
167 lines
6.6 KiB
Python
"""Local self-registration gate (``auth.local.allow_registration``).
|
|
|
|
The OIDC provisioning policy (``allowed_email_domains``, ``require_verified_email``,
|
|
``auto_create_users``) is enforced only in the SSO callback via
|
|
``get_or_provision_oidc_user``. ``POST /api/v1/auth/register`` creates a local account
|
|
without consulting any of it, so an SSO-provisioned deployment that declares a domain
|
|
allowlist can still be joined by any address through the local path. These tests pin the
|
|
gate that lets such a deployment close it.
|
|
"""
|
|
|
|
import secrets
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.gateway.auth.config import AuthConfig, set_auth_config
|
|
from deerflow.config.auth_config import AuthAppConfig, LocalAuthConfig
|
|
|
|
_TEST_SECRET = "test-secret-key-for-registration-gate-tests-only"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _persistence_engine(tmp_path):
|
|
"""Per-test SQLite engine + clean ``deps._cached_*`` (mirrors test_auth_type_system).
|
|
|
|
These tests drive the real ``/register`` handler, which reaches
|
|
``SQLiteUserRepository`` → ``get_session_factory``. A fresh DB per test also means
|
|
the admin slot is empty for the /initialize case.
|
|
"""
|
|
import asyncio
|
|
|
|
from app.gateway import deps
|
|
from deerflow.persistence.engine import close_engine, init_engine
|
|
|
|
asyncio.run(init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path}/registration_gate.db", sqlite_dir=str(tmp_path)))
|
|
deps._cached_local_provider = None
|
|
deps._cached_repo = None
|
|
try:
|
|
yield
|
|
finally:
|
|
deps._cached_local_provider = None
|
|
deps._cached_repo = None
|
|
asyncio.run(close_engine())
|
|
|
|
|
|
@pytest.fixture
|
|
def client(monkeypatch):
|
|
"""TestClient whose gate reads a config we control.
|
|
|
|
Overrides only ``auth.local`` on a deep copy of the real config: the app lifespan
|
|
reads other sections (``database`` above all) to initialise the engine, and a
|
|
narrower stub would abort startup and leak an uninitialised engine into the
|
|
globals ``app.gateway.deps`` caches for the rest of the session.
|
|
"""
|
|
from pathlib import Path
|
|
|
|
from deerflow.config.app_config import AppConfig
|
|
|
|
# config.yaml is gitignored, so tests cannot rely on it existing. The committed
|
|
# example is a valid config and gives every other section (database above all)
|
|
# a real value for the app lifespan.
|
|
baseline = AppConfig.from_file(str(Path(__file__).resolve().parents[2] / "config.example.yaml"))
|
|
|
|
def _make(*, allow_registration: bool) -> TestClient:
|
|
set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET))
|
|
patched = baseline.model_copy(deep=True)
|
|
patched.auth = AuthAppConfig(local=LocalAuthConfig(allow_registration=allow_registration), oidc=baseline.auth.oidc)
|
|
monkeypatch.setattr("deerflow.config.app_config.get_app_config", lambda: patched)
|
|
# setup-status memoizes per client IP; drop it so each direction is read fresh.
|
|
from app.gateway.routers import auth as auth_router
|
|
|
|
auth_router._SETUP_STATUS_CACHE.clear()
|
|
|
|
from app.gateway.app import create_app
|
|
|
|
return TestClient(create_app())
|
|
|
|
return _make
|
|
|
|
|
|
def _unique_email(prefix: str) -> str:
|
|
return f"{prefix}-{secrets.token_hex(4)}@test.com"
|
|
|
|
|
|
def test_register_succeeds_when_registration_allowed(client):
|
|
"""Default (allow_registration=True) keeps self-registration working."""
|
|
resp = client(allow_registration=True).post(
|
|
"/api/v1/auth/register",
|
|
json={"email": _unique_email("gate-allowed"), "password": "Tr0ub4dor3a!"},
|
|
)
|
|
assert resp.status_code == 201
|
|
|
|
|
|
def test_register_rejected_when_registration_disabled(client):
|
|
"""allow_registration=False closes the local self-registration path."""
|
|
resp = client(allow_registration=False).post(
|
|
"/api/v1/auth/register",
|
|
json={"email": _unique_email("gate-denied"), "password": "Tr0ub4dor3a!"},
|
|
)
|
|
assert resp.status_code == 403
|
|
assert resp.json()["detail"]["code"] == "registration_disabled"
|
|
|
|
|
|
def test_register_rejected_before_the_account_is_created(client):
|
|
"""The gate runs ahead of user creation — a denied email stays unregistered.
|
|
|
|
Guards against a gate placed after ``create_user``, which would 403 the caller
|
|
while still persisting the account.
|
|
"""
|
|
email = _unique_email("gate-not-persisted")
|
|
denied = client(allow_registration=False)
|
|
assert denied.post("/api/v1/auth/register", json={"email": email, "password": "Tr0ub4dor3a!"}).status_code == 403
|
|
|
|
# Re-open registration: the same address must still be free, i.e. the denied
|
|
# attempt created nothing. A leaked account would surface as 400 email_already_exists.
|
|
resp = client(allow_registration=True).post(
|
|
"/api/v1/auth/register",
|
|
json={"email": email, "password": "Tr0ub4dor3a!"},
|
|
)
|
|
assert resp.status_code == 201
|
|
|
|
|
|
def test_registration_defaults_to_allowed():
|
|
"""The default must stay True — this change is meant to add an opt-in, not flip behaviour.
|
|
|
|
Flipping the default would silently close self-registration on every existing
|
|
deployment that never sets the key.
|
|
"""
|
|
assert LocalAuthConfig().allow_registration is True
|
|
assert AuthAppConfig().local.allow_registration is True
|
|
|
|
|
|
def test_gate_falls_back_to_open_when_config_is_absent(monkeypatch):
|
|
"""No config.yaml → the pre-gate default (open), not a 500.
|
|
|
|
Bare-app contexts never load a config file. Before this gate, /register and
|
|
/setup-status did not read the app config at all; the fallback keeps that true.
|
|
"""
|
|
from app.gateway.routers import auth as auth_router
|
|
|
|
def _raise() -> None:
|
|
raise FileNotFoundError("`config.yaml` file not found in the project root")
|
|
|
|
monkeypatch.setattr("deerflow.config.app_config.get_app_config", _raise)
|
|
assert auth_router._local_registration_enabled() is True
|
|
|
|
|
|
@pytest.mark.parametrize("allowed", [True, False])
|
|
def test_setup_status_reports_registration_state(client, allowed):
|
|
"""The login page needs the flag to decide whether to offer a signup entry."""
|
|
resp = client(allow_registration=allowed).get("/api/v1/auth/setup-status")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["registration_enabled"] is allowed
|
|
|
|
|
|
def test_initialize_is_not_gated_by_the_registration_flag(client):
|
|
"""Closing self-registration must never block first-boot admin creation.
|
|
|
|
``config.example.yaml`` promises that turning the flag off cannot lock an operator
|
|
out of a fresh install; the per-test engine gives us a genuinely empty admin slot.
|
|
"""
|
|
resp = client(allow_registration=False).post(
|
|
"/api/v1/auth/initialize",
|
|
json={"email": _unique_email("gate-initialize"), "password": "Tr0ub4dor3a!"},
|
|
)
|
|
assert resp.status_code == 201
|