fix(sunset): gate SUNSET_MODE on DEPLOYMENT_MODE=cloud so self-hosted instances can never be sunset-blocked

This commit is contained in:
Anish Sarkar 2026-09-20 17:53:42 +05:30
parent 73202b1966
commit aae178d294
5 changed files with 50 additions and 8 deletions

View file

@ -106,7 +106,8 @@ NEXT_FRONTEND_URL=http://localhost:3000
# clients read once at startup to load the live /sunset page instead of their
# bundled frontend (plans/community-local/contracts/04-sunset-flag.md).
# Read per request, so the flip needs no redeploy. Accepts 1 / true / yes / on.
# Self-hosters leave this unset -- unset means "not sunset".
# Only takes effect when DEPLOYMENT_MODE=cloud -- self-hosted instances ignore
# this flag entirely, so leaving it unset (or set) makes no difference there.
SUNSET_MODE=
# Where those clients are sent. Defaults to https://surfsense.com/sunset.
SUNSET_URL=

View file

@ -473,7 +473,7 @@ class Config:
# Deployment Mode (self-hosted or cloud)
# self-hosted: Full access to local file system connectors (Obsidian, etc.)
# cloud: Only cloud-based connectors available
DEPLOYMENT_MODE = os.getenv("SURFSENSE_DEPLOYMENT_MODE", "self-hosted")
DEPLOYMENT_MODE = os.getenv("DEPLOYMENT_MODE", "self-hosted")
ENABLE_DESKTOP_LOCAL_FILESYSTEM = (
os.getenv("ENABLE_DESKTOP_LOCAL_FILESYSTEM", "FALSE").upper() == "TRUE"
)

View file

@ -6,9 +6,10 @@ flag that says so and the middleware that enforces it.
Two properties matter more than anything else here:
* **Self-hosters run this same code forever with the flag unset.** Every path
through this module has to be a no-op in that case, which is why the flag is
checked before anything else and defaults to off.
* **Self-hosters can never hit this, even by accident.** ``SUNSET_MODE`` alone
is not enough -- the flag only takes effect when ``DEPLOYMENT_MODE=cloud``,
so a stray ``SUNSET_MODE=1`` in a self-hosted ``.env`` (copied from a hosted
template, say) is a no-op rather than an outage.
* **Signing in has to keep working.** Export is behind a session, so blocking
authentication would lock people out of the one action still available and
make the 30-day window meaningless.
@ -58,10 +59,14 @@ _DETAIL = (
def is_sunset_mode() -> bool:
"""Whether the hosted service is winding down.
Read from the environment on every call rather than through ``config``,
which resolves at import: contract 4 requires that flipping the flag take
effect without a deploy.
Only ever true for a cloud deployment. ``SUNSET_MODE`` is read from the
environment on every call rather than cached, so flipping it takes effect
without a deploy; ``DEPLOYMENT_MODE`` is the safety net that keeps a
self-hosted instance immune to that flag regardless of what its own
``.env`` sets.
"""
if os.getenv("DEPLOYMENT_MODE", "self-hosted") != "cloud":
return False
return os.getenv("SUNSET_MODE", "").strip().lower() in _TRUTHY

View file

@ -44,6 +44,7 @@ def test_every_documented_spelling_turns_the_flag_on(client, monkeypatch, value)
Both have to work. The cost of a spelling that silently reads as false is
a sunset day where nothing happens and nothing says why.
"""
monkeypatch.setenv("DEPLOYMENT_MODE", "cloud")
monkeypatch.setenv("SUNSET_MODE", value)
assert _health(client)["sunset"] is True
@ -51,13 +52,23 @@ def test_every_documented_spelling_turns_the_flag_on(client, monkeypatch, value)
@pytest.mark.parametrize("value", ["", "0", "false", "no", "off", " "])
def test_anything_else_leaves_the_flag_off(client, monkeypatch, value):
monkeypatch.setenv("DEPLOYMENT_MODE", "cloud")
monkeypatch.setenv("SUNSET_MODE", value)
assert _health(client)["sunset"] is False
def test_self_hosted_stays_off_even_if_sunset_mode_is_set(client, monkeypatch):
"""A stray ``SUNSET_MODE=1`` in a self-hosted ``.env`` must be a no-op."""
monkeypatch.delenv("DEPLOYMENT_MODE", raising=False)
monkeypatch.setenv("SUNSET_MODE", "1")
assert _health(client)["sunset"] is False
def test_the_flag_is_read_per_request(client, monkeypatch):
"""No restart beyond the flag change, so it cannot be cached at import."""
monkeypatch.setenv("DEPLOYMENT_MODE", "cloud")
monkeypatch.delenv("SUNSET_MODE", raising=False)
assert _health(client)["sunset"] is False
@ -71,6 +82,7 @@ def test_sunset_is_a_json_boolean_not_a_string(client, monkeypatch):
``is True`` rather than ``== True`` on purpose: ``1 == True`` in Python, so
equality would pass on the raw environment value this once returned.
"""
monkeypatch.setenv("DEPLOYMENT_MODE", "cloud")
monkeypatch.setenv("SUNSET_MODE", "1")
assert isinstance(_health(client)["sunset"], bool)

View file

@ -66,6 +66,7 @@ def client() -> TestClient:
@pytest.fixture
def sunset_on(monkeypatch):
monkeypatch.setenv("DEPLOYMENT_MODE", "cloud")
monkeypatch.setenv("SUNSET_MODE", "1")
@ -92,13 +93,27 @@ def test_nothing_is_blocked_while_the_flag_is_unset(client, sunset_off, path, me
@pytest.mark.parametrize("value", ["", "0", "false", "no", "off", " "])
def test_a_flag_that_does_not_mean_yes_blocks_nothing(client, monkeypatch, value):
"""Anything short of an explicit yes leaves the service fully writable."""
monkeypatch.setenv("DEPLOYMENT_MODE", "cloud")
monkeypatch.setenv("SUNSET_MODE", value)
assert client.post("/api/v1/documents").status_code == 200
def test_self_hosted_ignores_a_stray_sunset_flag(client, monkeypatch):
"""``DEPLOYMENT_MODE`` defaults to self-hosted, which must win over SUNSET_MODE.
A ``.env`` copied from a hosted template can carry ``SUNSET_MODE=1`` by
accident; a self-hosted instance must stay fully writable regardless.
"""
monkeypatch.delenv("DEPLOYMENT_MODE", raising=False)
monkeypatch.setenv("SUNSET_MODE", "1")
assert client.post("/api/v1/documents").status_code == 200
def test_the_flag_is_read_per_request(client, monkeypatch):
"""Sunset is thrown by changing the environment, not by redeploying."""
monkeypatch.setenv("DEPLOYMENT_MODE", "cloud")
monkeypatch.delenv("SUNSET_MODE", raising=False)
assert client.post("/api/v1/documents").status_code == 200
@ -182,6 +197,7 @@ def test_the_scraper_api_keeps_running(client, sunset_on):
@pytest.mark.parametrize("value", ["1", "true", "TRUE", "True", "yes", "on"])
def test_every_documented_spelling_means_yes(monkeypatch, value):
monkeypatch.setenv("DEPLOYMENT_MODE", "cloud")
monkeypatch.setenv("SUNSET_MODE", value)
assert is_sunset_mode() is True
@ -189,6 +205,14 @@ def test_every_documented_spelling_means_yes(monkeypatch, value):
@pytest.mark.parametrize("value", ["", "0", "false", "no", "off", " ", "1 "])
def test_anything_else_means_no(monkeypatch, value):
monkeypatch.setenv("DEPLOYMENT_MODE", "cloud")
monkeypatch.setenv("SUNSET_MODE", value)
assert is_sunset_mode() is (value.strip() == "1")
def test_self_hosted_means_no_regardless_of_sunset_mode(monkeypatch):
monkeypatch.delenv("DEPLOYMENT_MODE", raising=False)
monkeypatch.setenv("SUNSET_MODE", "1")
assert is_sunset_mode() is False