Apache Syncope is one of those projects everyone in identity has heard of and few have read the source of. Over a few months I read enough of it to land five fixes upstream, and this is the write-up I wish I’d had going in. Two parts: first the map — features, stack, code layout — then a plain overview of the patches. No heroics; an approbation post.


Part 1 — The product

What it does

Syncope is an identity lifecycle engine. It owns the authoritative record of users, groups, and arbitrary “any-objects”, and propagates changes out to the systems that actually enforce access — an LDAP directory, a database, Azure AD, a SCIM endpoint. It is not an OIDC provider bolted onto a user table; it is the thing that decides a user exists, computes what that means on each connected resource, and pushes it there.

The feature surface, condensed:

  • Any-object model — User, Group, and user-defined AnyObject types, each with dynamic plain/derived/virtual schemas. One engine, not a hardcoded user table.
  • Realms — a hierarchical tenancy tree; entitlements and policies are granted on a realm subtree, giving delegated administration for free.
  • Provisioning — propagation (outbound), pull and push (inbound/bulk), and reconciliation against connected resources, all via ConnId connectors.
  • Access management — CAS / OIDC / SAML surface built on Apereo WA, so the same identity store backs SSO.
  • Policies & workflow — password, account, provisioning, and pull policies; approval workflows (BPMN via Flowable, or a Java default).
  • Audit, notifications, reports — every operation is auditable; JEXL-templated mail notifications fire on events; self-service and SCIM 2.0 round it out.

The tech stack

Standard, boring, current — which is the point:

ConcernChoice
Language / buildJava (17+), Maven multi-module reactor
RuntimeSpring Boot 4.1
REST layerJAX-RS via Apache CXF 4.2
PersistenceHibernate 7 / JPA or Neo4j — pick one
ProvisioningConnId connectors (LDAP, DB, Azure, SCIM, …)
SearchJPA queries, or Elasticsearch 9 / OpenSearch 3
Web UIsApache Wicket 10 (Console + Enduser)
Access managementApereo WA / CAS
JSONJackson 3 (tools.jackson.*)

The Jackson 3 detail matters later — the migration off Jackson 2 caused one of the bugs I fixed.

The codebase

Two axes organize everything. Domain splits the product three ways, and each domain repeats the same layering:

domains →   idrepo         idm            am
            (identity      (provisioning: (access mgmt:
             repository)    connectors)    CAS/OIDC/SAML)

layers ↓
  common/   REST DTOs + client-facing lib   (no server deps)
  client/   typed Java SDK over the REST API
  core/     the server: logic + persistence + provisioning

common deliberately has no server dependencies, so integration code pulls common + client and never sees a Hibernate class. Internalize domain × layer and you can find any file in the tree.

How a request flows

The Console and Enduser web apps are not privileged insiders — they are REST clients, same as anything you write. Everything goes through CXF. A user update touches every layer, and reveals the one thing I kept getting wrong:

  Console / Enduser / your SDK code
              │  HTTP (JSON)
              ▼
  ┌───────────────────────────┐
  │  REST layer  (Apache CXF)  │
  └─────────────┬─────────────┘
                ▼
  ┌───────────────────────────┐
  │  *Logic  (auth, policy)    │
  └─────────────┬─────────────┘
      ┌─────────┴───────────┐
      ▼                     ▼
┌────────────┐      ┌──────────────────────┐
│ Persistence│      │ Provisioning         │
│ JPA / Neo4j│      │ PropagationManager   │
└────────────┘      └──────────┬───────────┘
  internal state               │  ConnId
                               ▼
                ┌────────────────────────────┐
                │ LDAP · DB · Azure AD · SCIM │
                └────────────────────────────┘

Persistence and provisioning are two separate writes. Committing a user to the JPA store is one thing; computing the per-resource ConnectorObject and shipping it through a ConnId connector is a separate pipeline. A create can succeed internally and still fail propagation to one downstream resource — by design, which is why PropagationTask and its execution history are first-class entities. Every patch below lives somewhere on this diagram.


Part 2 — Five patches

All merged into master (the 4.0 line). Grouped by the area of the diagram they touch.

Provisioning: outbound correctness

[SYNCOPE-1921] — LDAP group membership preserved on propagation (#1362)

LDAPMembershipPropagationActions has a block that re-attaches a user’s externally-managed LDAP groups so Syncope doesn’t stomp them on update. It was a silent no-op whenever ldapGroups wasn’t in the user mapping: the “before” object fetched from the directory simply didn’t carry that attribute, so there was nothing to preserve. The fix is one override — tell the connector to fetch it:

@Override
public Set<String> moreAttrsToGet(
        Optional<PropagationTaskInfo> taskInfo, Provision provision) {
    return Set.of("ldapGroups");   // pull it into beforeObj so preservation runs
}

New IT issueSYNCOPE1921 reproduces the scenario without ldapGroups in the mapping — red before, green after.

Provisioning: notifications after delete

[SYNCOPE-1744] — notification template context survives user delete (#1352)

Notifications bound to UserLogic:delete:SUCCESS render mail from a JEXL template like ${user.getPlainAttr("email")...}. But by the time the notification is built, the user is gone — userDAO.findById returns empty, so the JEXL context had no user variable and templates evaluated to blank. The before snapshot (a full UserTO) was already in hand but unused. Fix: in DefaultNotificationManager.createTasks, fall back to that snapshot to populate the user / group / anyObject JEXL variables when the live entity is gone. Unit-tested with an empty findById and a plain-attribute template.

Console: a Jackson 3 regression

[SYNCOPE-1980] — empty audit-history diff under Jackson 3 (#1439)

The per-entity Audit History modal always showed two empty JSON panes. Root cause is a clean example of a Jackson 2→3 migration trap: an untyped ObjectReader.

// before — Jackson 2 tolerated a missing root type; Jackson 3 throws
//   InvalidDefinitionException: No value type configured for ObjectReader
T entity = MAPPER.reader()
        .with(StreamReadFeature.STRICT_DUPLICATE_DETECTION)
        .readValue(content);

// after — read into the concrete type the panel already holds
@SuppressWarnings("unchecked")
T entity = (T) MAPPER.readerFor(currentEntity.getClass())
        .with(StreamReadFeature.STRICT_DUPLICATE_DETECTION)
        .readValue(content);

The exception was swallowed by a surrounding try/catch, so the failure was invisible except in the log — the worst kind of regression. One line to fix.

The last two are a small feature, split across two PRs on review feedback. Motivation: after the 4.0 audit refactor, entityKey correctly matches only a UUID — which means once a user is deleted you can no longer find their audit trail, because the UUID is gone. But the username is still embedded in the before snapshot of the delete event.

[SYNCOPE-1981] — search audit events by who (#1438) adds a repeatable who filter (the principal who performed the action) — “what did admin X do”. [SYNCOPE-1978] — search audit events by username (#1443) adds a repeatable username filter matching the affected entity inside the serialized payload. Both are exact-match, OR-able, and thread the same shape through AuditServiceImpl → AuditLogic → AuditEventDAO and every backend:

who=jsmith&who=admin           →  match any of these actors
username=jdoe&username=asmith  →  match any of these subjects

  JPA              WHERE who IN (?, ?)     /  beforeValue LIKE ? ESCAPE '#'
  Neo4j            n.who IN $who           /  ANY(u IN $usernames WHERE n.before CONTAINS u)
  ES / OpenSearch  term query, OR-ed       /  multi_match phrase "\"username\":\"<v>\""

Every value is bound as a query parameter (SQL/Cypher) or a structured phrase/term query (ES/OpenSearch) — no string interpolation, no injection surface, and LIKE metacharacters in a username are escaped so they match literally. ITs in AuditITCase cover exact match, OR, non-match, literal-*, and the deleted-user case end to end against PostgreSQL, Neo4j, and Elasticsearch.


None of these are large. That’s rather the point: Syncope’s domain boundaries are clean, the SPIs are small and default-driven, and the failure modes are specific enough that a first-time reader can find them. If you work near identity and have been meaning to make an upstream contribution, it’s an unusually approachable place to start — and the maintainers actually read your patch, usually within a day.