Issue Description
Summary
synchronize_account() in crates/common/src/cache/directory.rs unconditionally overwrites an account’s member_group_ids to match the directory’s asserted account.groups on every successful authentication. When the directory doesn’t assert any group membership at all (e.g. an OIDC provider configured without a group claim), account.groups is always an empty Vec, so every successful login silently clears any Group membership the account has – including memberships that were set independently of the directory (e.g. a native Stalwart Group used for shared-mailbox/calendar/address-book access).
Reproduction
- Configure an
OpenIddirectory withoutclaim_groupsset. - Create a native Stalwart
Groupaccount and add aUseraccount to it viamember_group_ids(either through the registry API directly, or via any other non-directory-driven path). - Authenticate that user via the OIDC directory (a Bearer token that validates successfully).
- Observe:
member_group_idson the user account is now empty. The group’s shared mailbox/calendar/etc. is no longer visible to that user.
This reproduces on every successful login, not just failed ones – in practice it showed up as a background webapp session-refresh (any periodic Bearer-authenticated request) repeatedly clearing group membership every ~30s.
Root cause
// crates/common/src/cache/directory.rs, synchronize_account(), existing-account path
let mut member_group_ids = Vec::with_capacity(account.groups.len());
for email in account.groups {
member_group_ids.push(/* ... resolve each asserted group to an id ... */);
}
if updated_account.member_group_ids.len() != member_group_ids.len()
|| !updated_account.member_group_ids.iter().all(|id| member_group_ids.contains(id))
{
updated_account.member_group_ids = member_group_ids.into();
has_changes = true;
}
account.groups comes from the directory backend’s claim resolution – for OIDC (crates/directory/src/backend/oidc/lookup.rs, build_account()), it’s self.config.claim_groups.as_ref().and_then(...).unwrap_or_default(), which is always an empty Vec when claim_groups isn’t configured. The sync above doesn’t distinguish “the directory asserts zero groups” from “the directory doesn’t manage groups at all” – it treats both identically and destructively overwrites either way.
Suggested fix
Only apply the sync when the directory actually asserts at least one group:
if !member_group_ids.is_empty()
&& (updated_account.member_group_ids.len() != member_group_ids.len()
|| !updated_account.member_group_ids.iter().all(|id| member_group_ids.contains(id)))
{
updated_account.member_group_ids = member_group_ids.into();
has_changes = true;
}
An empty assertion is treated as “the directory has no opinion about group membership,” not “clear everything.” Directories that DO assert groups (a properly configured LDAP/SQL/OIDC group-claim setup) keep the exact same two-way sync behavior as before – this only changes what happens when the directory returns zero groups for an account that has locally-managed group memberships.
I’ve verified this fix against a live 0.16.12 deployment (compiles clean, cargo check -p common passes with no new warnings, resolves the reproduction case end-to-end) and against current main (same code path, same fix applies cleanly). Diff for reference: fix(directory): don't clear locally-managed group membership on empty… · caseyandgina/stalwart@f8a93c8 · GitHub
Stalwart Version
v0.16.x
Installation Method
Binary (FreeBSD)
Database Backend
PostgreSQL
Blob Storage
S3-compatible
Search Engine
PostgreSQL
Directory Backend
OIDC
I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.
on
I have searched this support forum (open and closed topics) and confirm this is not a duplicate.
on
I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.
on