‼️ Severe data-loss bug in OX Connector 4.0.0 caused by legacy cached oxContext representation vs Nubus provisioning representation

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:

  1. incorrectly detects a context move;
  2. searches context 10 as the supposed new context;
  3. finds the same existing user;
  4. enters the “found in two contexts” branch;
  5. deletes the existing OX user;
  6. tries to modify the now-deleted user;
  7. receives “No such user”;
  8. falls back to creating a new user;
  9. 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:

  1. The legacy OX UDM syntax returns oxContext as a string:

    return str(int(text))

    ox-connector/udm/syntax.d/50_ox.py at 8d7bf4b59410010404bea96a6d1fd4969abbd87e · univention/ox-connector · GitHub

  2. 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
    

    ox-connector/univention-ox-provisioning/univention/ox/provisioning/helpers.py at 8d7bf4b59410010404bea96a6d1fd4969abbd87e · univention/ox-connector · GitHub

  3. User modification compares the raw old and new values directly:

    if old_context != new_context:
    

    and can subsequently execute:

    delete_user(deepcopy(obj))
    

    ox-connector/univention-ox-provisioning/univention/ox/provisioning/users.py at 8d7bf4b59410010404bea96a6d1fd4969abbd87e · univention/ox-connector · GitHub

  4. The 4.0 standalone consumer explicitly ignores the provisioning message’s body.old and takes the current object’s properties directly from body.new:

    # We ignore body.old because it is read from the DB
    ...
    obj_attrs = body.new.get("properties")
    

    ox-connector/standalone-files/consumer.py at 8d7bf4b59410010404bea96a6d1fd4969abbd87e · univention/ox-connector · GitHub

  5. The historical state is independently loaded from the connector database:

    obj.old_attributes = (
        json.loads(old.attrs) if old.attrs else None
    )
    

    ox-connector/standalone-files/consumer.py at 8d7bf4b59410010404bea96a6d1fd4969abbd87e · univention/ox-connector · GitHub

    json.loads() preserves the distinction between JSON "10" and JSON 10.

  6. Nubus Prefill fetches the current UDM REST object as JSON and returns it without property-specific conversion:

    provisioning-api/prefill/src/univention/provisioning/prefill/udm_adapter.py at main · univention/provisioning-api · GitHub

    It subsequently sends that object directly as the new provisioning state:

    body=Body(old={}, new=obj)
    

    provisioning-api/prefill/src/univention/provisioning/prefill/prefill_service.py at main · univention/provisioning-api · GitHub

  7. The same raw old_context != new_context comparison is also present in resource provisioning:

    ox-connector/univention-ox-provisioning/univention/ox/provisioning/resources.py at 8d7bf4b59410010404bea96a6d1fd4969abbd87e · univention/ox-connector · GitHub

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.

1 Like

bugreport here: Log in to Univention Bugzilla

1 Like