Compare commits

...

3 Commits

5 changed files with 82 additions and 24 deletions

View File

@ -55,11 +55,11 @@ class _FakeResponse:
class _FakeSession: class _FakeSession:
def __init__(self, put_resp, get_resp): def __init__(self, put_resp, get_resps):
self.put_resp = put_resp self.put_resp = put_resp
self.get_resp = get_resp self.get_resps = list(get_resps)
self.put_called = False self.put_called = False
self.get_called = False self.get_calls = 0
def post(self, *args, **kwargs): def post(self, *args, **kwargs):
return _FakeResponse({"access_token": "dummy"}) return _FakeResponse({"access_token": "dummy"})
@ -69,22 +69,26 @@ class _FakeSession:
return self.put_resp return self.put_resp
def get(self, *args, **kwargs): def get(self, *args, **kwargs):
self.get_called = True self.get_calls += 1
return self.get_resp if self.get_resps:
return self.get_resps.pop(0)
return _FakeResponse({})
def test_kc_update_attributes_succeeds(monkeypatch): def test_kc_update_attributes_succeeds(monkeypatch):
sync = load_sync_module(monkeypatch) sync = load_sync_module(monkeypatch)
current_resp = _FakeResponse({"attributes": {}})
ok_resp = _FakeResponse({"attributes": {"mailu_app_password": ["abc"]}}) ok_resp = _FakeResponse({"attributes": {"mailu_app_password": ["abc"]}})
sync.SESSION = _FakeSession(_FakeResponse({}), ok_resp) sync.SESSION = _FakeSession(_FakeResponse({}), [current_resp, ok_resp])
sync.kc_update_attributes("token", {"id": "u1", "username": "u1"}, {"mailu_app_password": "abc"}) sync.kc_update_attributes("token", {"id": "u1", "username": "u1"}, {"mailu_app_password": "abc"})
assert sync.SESSION.put_called and sync.SESSION.get_called assert sync.SESSION.put_called and sync.SESSION.get_calls == 2
def test_kc_update_attributes_raises_without_attribute(monkeypatch): def test_kc_update_attributes_raises_without_attribute(monkeypatch):
sync = load_sync_module(monkeypatch) sync = load_sync_module(monkeypatch)
current_resp = _FakeResponse({"attributes": {}})
missing_attr_resp = _FakeResponse({"attributes": {}}, status=200) missing_attr_resp = _FakeResponse({"attributes": {}}, status=200)
sync.SESSION = _FakeSession(_FakeResponse({}), missing_attr_resp) sync.SESSION = _FakeSession(_FakeResponse({}), [current_resp, missing_attr_resp])
with pytest.raises(Exception): with pytest.raises(Exception):
sync.kc_update_attributes("token", {"id": "u1", "username": "u1"}, {"mailu_app_password": "abc"}) sync.kc_update_attributes("token", {"id": "u1", "username": "u1"}, {"mailu_app_password": "abc"})
@ -144,8 +148,18 @@ def test_main_generates_password_and_upserts(monkeypatch):
sync = load_sync_module(monkeypatch) sync = load_sync_module(monkeypatch)
monkeypatch.setattr(sync.bcrypt_sha256, "hash", lambda password: f"hash:{password}") monkeypatch.setattr(sync.bcrypt_sha256, "hash", lambda password: f"hash:{password}")
users = [ users = [
{"id": "u1", "username": "user1", "email": "user1@example.com", "attributes": {}}, {
{"id": "u2", "username": "user2", "email": "user2@example.com", "attributes": {"mailu_app_password": ["keepme"]}}, "id": "u1",
"username": "user1",
"email": "user1@example.com",
"attributes": {"mailu_enabled": ["true"]},
},
{
"id": "u2",
"username": "user2",
"email": "user2@example.com",
"attributes": {"mailu_app_password": ["keepme"], "mailu_enabled": ["true"]},
},
{"id": "u3", "username": "user3", "email": "user3@other.com", "attributes": {}}, {"id": "u3", "username": "user3", "email": "user3@other.com", "attributes": {}},
] ]
updated = [] updated = []
@ -185,6 +199,6 @@ def test_main_generates_password_and_upserts(monkeypatch):
sync.main() sync.main()
# Always backfill mailu_email, even if Keycloak recovery email is external. # Only mail-enabled users are synced and backfilled.
assert len(updated) == 3 assert len(updated) == 2
assert conns and len(conns[0]._cursor.executions) == 3 assert conns and len(conns[0]._cursor.executions) == 2

View File

@ -33,6 +33,15 @@ spec:
export PORTAL_E2E_CLIENT_ID="{{ .Data.data.client_id }}" export PORTAL_E2E_CLIENT_ID="{{ .Data.data.client_id }}"
export PORTAL_E2E_CLIENT_SECRET="{{ .Data.data.client_secret }}" export PORTAL_E2E_CLIENT_SECRET="{{ .Data.data.client_secret }}"
{{ end }} {{ end }}
{{ with secret "kv/data/atlas/shared/postmark-relay" }}
export SMTP_HOST="mail.bstein.dev"
export SMTP_PORT="587"
export SMTP_STARTTLS="true"
export SMTP_USE_TLS="false"
export SMTP_USERNAME="{{ index .Data.data "relay-username" }}"
export SMTP_PASSWORD="{{ index .Data.data "relay-password" }}"
export SMTP_FROM="no-reply-portal@bstein.dev"
{{ end }}
spec: spec:
automountServiceAccountToken: true automountServiceAccountToken: true
serviceAccountName: bstein-dev-home serviceAccountName: bstein-dev-home

View File

@ -6,6 +6,7 @@ declare(strict_types=1);
use FireflyIII\Console\Commands\Correction\CreatesGroupMemberships; use FireflyIII\Console\Commands\Correction\CreatesGroupMemberships;
use FireflyIII\Models\Role; use FireflyIII\Models\Role;
use FireflyIII\Repositories\User\UserRepositoryInterface; use FireflyIII\Repositories\User\UserRepositoryInterface;
use FireflyIII\Support\Facades\FireflyConfig;
use FireflyIII\User; use FireflyIII\User;
use Illuminate\Contracts\Console\Kernel as ConsoleKernel; use Illuminate\Contracts\Console\Kernel as ConsoleKernel;
@ -70,6 +71,12 @@ $app = require $app_bootstrap;
$kernel = $app->make(ConsoleKernel::class); $kernel = $app->make(ConsoleKernel::class);
$kernel->bootstrap(); $kernel->bootstrap();
try {
FireflyConfig::set('single_user_mode', true);
} catch (Throwable $exc) {
error_line('failed to enforce single_user_mode: '.$exc->getMessage());
}
$repository = $app->make(UserRepositoryInterface::class); $repository = $app->make(UserRepositoryInterface::class);
$existing_user = User::where('email', $email)->first(); $existing_user = User::where('email', $email)->first();

View File

@ -220,6 +220,14 @@ spec:
"permissions": {"view": ["admin"], "edit": ["admin"]}, "permissions": {"view": ["admin"], "edit": ["admin"]},
"validations": {"length": {"max": 255}}, "validations": {"length": {"max": 255}},
}, },
{
"name": "mailu_enabled",
"displayName": "Atlas Mailbox Enabled",
"multivalued": False,
"annotations": {"group": "user-metadata"},
"permissions": {"view": ["admin"], "edit": ["admin"]},
"validations": {"length": {"max": 16}},
},
{ {
"name": "nextcloud_mail_primary_email", "name": "nextcloud_mail_primary_email",
"displayName": "Nextcloud Mail Primary Email", "displayName": "Nextcloud Mail Primary Email",

View File

@ -25,6 +25,7 @@ KC_CLIENT_SECRET = os.environ["KEYCLOAK_CLIENT_SECRET"]
MAILU_DOMAIN = os.environ["MAILU_DOMAIN"] MAILU_DOMAIN = os.environ["MAILU_DOMAIN"]
MAILU_DEFAULT_QUOTA = int(os.environ.get("MAILU_DEFAULT_QUOTA", "20000000000")) MAILU_DEFAULT_QUOTA = int(os.environ.get("MAILU_DEFAULT_QUOTA", "20000000000"))
MAILU_ENABLED_ATTR = os.environ.get("MAILU_ENABLED_ATTR", "mailu_enabled")
DB_CONFIG = { DB_CONFIG = {
"host": os.environ["MAILU_DB_HOST"], "host": os.environ["MAILU_DB_HOST"],
@ -86,7 +87,12 @@ def kc_get_users(token):
while True: while True:
resp = SESSION.get( resp = SESSION.get(
f"{KC_BASE}/admin/realms/{KC_REALM}/users", f"{KC_BASE}/admin/realms/{KC_REALM}/users",
params={"first": first, "max": max_results, "enabled": "true"}, params={
"first": first,
"max": max_results,
"enabled": "true",
"briefRepresentation": "false",
},
headers=headers, headers=headers,
timeout=20, timeout=20,
) )
@ -104,17 +110,20 @@ def kc_update_attributes(token, user, attributes):
"Authorization": f"Bearer {token}", "Authorization": f"Bearer {token}",
"Content-Type": "application/json", "Content-Type": "application/json",
} }
payload = {
"firstName": user.get("firstName"),
"lastName": user.get("lastName"),
"email": user.get("email"),
"enabled": user.get("enabled", True),
"username": user["username"],
"emailVerified": user.get("emailVerified", False),
"attributes": attributes,
}
user_url = f"{KC_BASE}/admin/realms/{KC_REALM}/users/{user['id']}" user_url = f"{KC_BASE}/admin/realms/{KC_REALM}/users/{user['id']}"
resp = SESSION.put(user_url, headers=headers, json=payload, timeout=20) current = SESSION.get(
user_url,
headers={"Authorization": f"Bearer {token}"},
params={"briefRepresentation": "false"},
timeout=15,
)
current.raise_for_status()
current_payload = current.json()
current_attrs = current_payload.get("attributes") if isinstance(current_payload, dict) else None
if not isinstance(current_attrs, dict):
current_attrs = {}
current_attrs.update(attributes)
resp = SESSION.put(user_url, headers=headers, json={"attributes": current_attrs}, timeout=20)
resp.raise_for_status() resp.raise_for_status()
verify = SESSION.get( verify = SESSION.get(
user_url, user_url,
@ -141,6 +150,13 @@ def get_attribute_value(attributes, key):
return None return None
def mailu_enabled(attributes) -> bool:
raw = get_attribute_value(attributes, MAILU_ENABLED_ATTR)
if raw is None:
return False
return str(raw).strip().lower() in {"1", "true", "yes", "y", "on"}
def resolve_mailu_email(user, attributes): def resolve_mailu_email(user, attributes):
explicit = get_attribute_value(attributes, "mailu_email") explicit = get_attribute_value(attributes, "mailu_email")
if explicit: if explicit:
@ -209,6 +225,10 @@ def main():
for user in users: for user in users:
attrs = user.get("attributes", {}) or {} attrs = user.get("attributes", {}) or {}
if user.get("enabled") is False:
continue
if not mailu_enabled(attrs):
continue
app_pw = get_attribute_value(attrs, "mailu_app_password") app_pw = get_attribute_value(attrs, "mailu_app_password")
mailu_email = resolve_mailu_email(user, attrs) mailu_email = resolve_mailu_email(user, attrs)