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-definedAnyObjecttypes, 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:
| Concern | Choice |
|---|---|
| Language / build | Java (17+), Maven multi-module reactor |
| Runtime | Spring Boot 4.1 |
| REST layer | JAX-RS via Apache CXF 4.2 |
| Persistence | Hibernate 7 / JPA or Neo4j — pick one |
| Provisioning | ConnId connectors (LDAP, DB, Azure, SCIM, …) |
| Search | JPA queries, or Elasticsearch 9 / OpenSearch 3 |
| Web UIs | Apache Wicket 10 (Console + Enduser) |
| Access management | Apereo WA / CAS |
| JSON | Jackson 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.
idrepo: audit search
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.