Hello everyone, this is quite a big one, as it caused the deletion of a lot of our ox users and their calendars, contacts, configured external inboxes etc.
Id also like to know if there is any way to recover the data deleted by OX Connector
Summary
After upgrading to OX Connector 4.0.0, the connector incorrectly detected context changes for existing users whose OX context had not actually changed.
The connector reused pre-existing cached state originating from the legacy listener-based provisioning path. That state contains oxContext as JSON text, for example:
"oxContext": "10"
OX Connector 4.0.0 receives the current object through Nubus / UDM REST, where the same value is represented as a JSON integer:
"oxContext": 10
OX Connector 4.0.0 compares these values without normalization:
if old_context != new_context:
Therefore:
"10" != 10
evaluates to True .
The connector consequently enters the context-move code path even though the logical context is unchanged. Because both representations later resolve to the same actual OX context, the connector finds the same existing OX user in the supposed “new” context, deletes it, then recreates it with a new OX ID.
This caused loss of groupware data tied to the old OX user identity, including calendars.
Important upgrade-history note
We cannot currently state with certainty which exact historical OX Connector version first wrote the string representation.
The affected installation had been upgraded through multiple OX Connector versions before 4.0.0.
The important point is that the old connector state was preserved across those upgrades and still contained legacy values such as:
"oxContext": "10"
The type mismatch only became destructive when OX Connector 4.0.0 began comparing that preserved legacy state with the Nubus / UDM REST representation:
"oxContext": 10
Therefore the bug should be understood as a migration-compatibility issue between preserved legacy connector state and the new 4.0.0 provisioning representation, rather than necessarily as a direct 2.3.5 → 4.0.0 upgrade issue.
Direct evidence from the migrated incident database
The migrated connector database contains both representations:
SELECT
json_type(attrs, "$.oxContext") AS ctx_type,
COUNT(*) AS count
FROM old
WHERE json_extract(attrs, "$.oxContext") IS NOT NULL
GROUP BY json_type(attrs, "$.oxContext");
Result:
ctx_type count
-------- -----
integer 184
text 989
This means 989 cached objects still contain the legacy representation:
"oxContext": "10"
while 184 objects contain the newer representation:
"oxContext": 10
Examples of preserved legacy rows include:
id username ctx_type oxContext dbid_type oxDbId
---- -------- -------- --------- --------- ------
5 oxadmin text 10
7 user1 text 10 integer 8
8 user2 text 10 integer 9
The coexistence of these two representations inside the migrated state demonstrates that historical connector data was preserved without normalizing the type of oxContext .
New Nubus / UDM REST representation
Using the same UDM adapter used by the Nubus prefill service, an affected user currently returns:
oxContext value: 10
oxContext type: <class 'int'>
Therefore the new provisioning representation is:
"oxContext": 10
Root cause
The connector combines:
old_context = "10" # preserved legacy connector state
new_context = 10 # current Nubus / UDM REST object
and performs the raw comparison:
if old_context != new_context:
which evaluates to true.
This incorrectly triggers the destructive context-move logic.
The SOAP layer later normalizes both representations to the same actual context ID, so both "10" and 10 address OX context 10.
The connector therefore:
- incorrectly detects a context move;
- searches context 10 as the supposed new context;
- finds the same existing user;
- enters the “found in two contexts” branch;
- deletes the existing OX user;
- tries to modify the now-deleted user;
- receives “No such user”;
- falls back to creating a new user;
- receives a new sequential OX ID.
Why this surfaced with 4.0.0
The destructive comparison logic itself may predate 4.0.0.
The triggering compatibility problem is that 4.0.0 combines two different serialization domains:
preserved legacy connector cache:
oxContext = "10"
new Nubus / UDM REST provisioning:
oxContext = 10
The pre-existing cached data was retained during upgrade, while the incoming representation changed.
Because the new Nubus subscriber performs prefill of existing objects, the mismatch is exercised against a large part of the existing user population immediately after upgrade.
After an object is processed, its cached old state is rewritten using the new integer representation. This explains why the incident database contains both:
989 text rows
184 integer rows
and why already-processed affected users now appear with integer oxContext .
Recommended fix
Normalize context IDs centrally before comparison or grouping.
For example:
def get_context_id(attributes):
context_id = attributes.get("oxContext")
if context_id is None:
raise Skip("Object has no oxContext attribute!")
if isinstance(context_id, bool):
raise Skip(f"Invalid oxContext value: {context_id!r}")
if isinstance(context_id, int):
return context_id
if isinstance(context_id, str):
try:
return int(context_id)
except ValueError as exc:
raise Skip(
f"Invalid oxContext value: {context_id!r}"
) from exc
raise Skip(
f"Invalid oxContext type: {type(context_id).__name__}"
)
Additionally, destructive context-move operations should fail closed if the normalized old and new context IDs are equal.
Root cause summary
The production incident was caused by a migration compatibility bug in OX Connector 4.0.0.
Pre-existing connector state preserved from the legacy listener-based provisioning path stored oxContext as a string:
old_context = "10"
The new Nubus / UDM REST path supplies it as an integer:
new_context = 10
OX Connector 4.0.0 compares these unnormalized values directly:
"10" != 10
and incorrectly interprets the result as a real context move.
This causes the connector to delete the existing OX user from the same actual context and recreate it with a new OX ID, resulting in loss of identity-bound Open-Xchange groupware data.
Source references
The relevant behavior can be traced directly in the Univention source code:
-
The legacy OX UDM syntax returns
oxContextas a string:return str(int(text)) -
The OX Connector helper does not normalize the value:
def get_context_id(attributes): context_id = attributes.get("oxContext") if context_id is None: raise Skip("Object has no oxContext attribute!") return context_id -
User modification compares the raw old and new values directly:
if old_context != new_context:and can subsequently execute:
delete_user(deepcopy(obj)) -
The 4.0 standalone consumer explicitly ignores the provisioning message’s
body.oldand takes the current object’s properties directly frombody.new:# We ignore body.old because it is read from the DB ... obj_attrs = body.new.get("properties") -
The historical state is independently loaded from the connector database:
obj.old_attributes = ( json.loads(old.attrs) if old.attrs else None )json.loads()preserves the distinction between JSON"10"and JSON10. -
Nubus Prefill fetches the current UDM REST object as JSON and returns it without property-specific conversion:
It subsequently sends that object directly as the new provisioning state:
body=Body(old={}, new=obj) -
The same raw
old_context != new_contextcomparison is also present in resource provisioning:
Together with the production incident database, which contains 989 cached rows where json_type(attrs, "$.oxContext") = "text" and 184 rows where it is "integer", these source paths show how preserved legacy state and the new Nubus representation can produce:
old_context = "10"
new_context = 10
old_context != new_context # True
and thereby incorrectly enter the destructive context-move path.