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 modelUser, 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.
  • Provisioningpropagation (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 goneuserDAO.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.