diff --git a/README.md b/README.md
index f9ef532..957b066 100644
--- a/README.md
+++ b/README.md
@@ -251,6 +251,12 @@ Permit, else rejects it. For more information, see the Javadoc of
[RESTfulPdpBasedAuthzInterceptorTest](webapp/src/test/java/org/ow2/authzforce/webapp/test/pep/cxf/RESTfulPdpBasedAuthzInterceptorTest.java).
+### PAP change listener
+
+For optional notifications of successful policy, PDP configuration and
+attribute-provider configuration changes, see
+[PAP change notifications](docs/pap-change-listener.md).
+
## Testing
To run unit tests, install Maven and type
diff --git a/dist/src/webapp-context.xml b/dist/src/webapp-context.xml
index 2dbf0c0..02d5287 100644
--- a/dist/src/webapp-context.xml
+++ b/dist/src/webapp-context.xml
@@ -12,6 +12,9 @@
override="false" />
+
@@ -83,5 +86,4 @@
type="java.lang.Integer"
value="134217728"
override="false" />
-
diff --git a/docs/pap-change-listener.md b/docs/pap-change-listener.md
new file mode 100644
index 0000000..8ff9eb7
--- /dev/null
+++ b/docs/pap-change-listener.md
@@ -0,0 +1,117 @@
+# PAP change notifications
+
+AuthzForce Server can notify an optional Java listener after successful PAP API
+operations. No listener is configured by default. This extension does not require
+additional runtime libraries in the standard server distribution.
+
+## Contract
+
+Implement `org.ow2.authzforce.rest.service.PapChangeListener`:
+
+```java
+package example;
+
+import org.ow2.authzforce.rest.service.PapChangeEvent;
+import org.ow2.authzforce.rest.service.PapChangeListener;
+
+public final class MyPapListener implements PapChangeListener {
+ public MyPapListener() { }
+
+ @Override
+ public void papChanged(PapChangeEvent event) {
+ // Record the notification in your integration's persistent queue.
+ // A separate worker can publish it to a broker.
+ }
+}
+```
+
+Compile the extension against the matching version of
+`org.ow2.authzforce:authzforce-ce-server-rest-service` (normally with Maven
+`provided` scope). Do not bundle a second copy of the server API classes.
+The listener must have a public no-argument constructor. The server creates one
+instance per web application, using its classloader; configuration of the
+listener's own infrastructure is the extension's responsibility.
+
+Callbacks run synchronously on request threads. The implementation must be
+thread-safe and finish promptly; its duration adds to PAP request latency.
+Concurrent operations may produce callbacks in a different order from storage
+mutations. An event UUID and timestamp are not a domain revision.
+
+Runtime exceptions from the callback are logged and do not turn an already
+successful PAP operation into an HTTP failure. Fatal JVM errors are not caught.
+If the listener implements `AutoCloseable`, Spring closes it when the web
+application context is destroyed. Coordinate shutdown of any workers owned by
+the extension.
+
+## Install and configure
+
+1. Put the extension JAR and its additional runtime dependencies on the web
+ application's classpath, for example in
+ `/opt/authzforce-ce-server/webapp/WEB-INF/lib`.
+2. Set the following entry in the Tomcat context configuration (for example
+ `/etc/tomcat11/Catalina/localhost/authzforce-ce.xml`):
+
+ ```xml
+
+ ```
+
+3. Redeploy/restart the web application.
+
+An absent or empty entry disables notifications. A nonempty entry naming an
+unavailable class, a class that does not implement the interface, or a listener
+that cannot be constructed causes startup to fail. This avoids silently running
+without an explicitly configured integration.
+
+Changing the extension requires building its JAR and updating the deployment,
+not recompiling AuthzForce source code. This is not hot loading. Review
+compatibility with the listener API when upgrading the server.
+
+## Events
+
+All events contain an event UUID, creation timestamp, domain ID and operation.
+Policy identifiers are supplied only where applicable:
+
+| Operation | Policy ID | Version |
+| --- | --- | --- |
+| `POLICY_VERSION_ADDED` | Added policy | Added version |
+| `POLICY_VERSION_DELETED` | Affected policy | Actual deleted version, including when requested through `latest` |
+| `POLICY_DELETED` | Removed policy | Absent; operation covers all removed versions |
+| `PDP_CONFIGURATION_CHANGED` | Absent | Absent |
+| `ATTRIBUTE_PROVIDERS_CHANGED` | Absent | Absent |
+| `DOMAIN_DELETED` | Absent | Absent |
+
+Notifications describe successful operations, not a computed content diff.
+A successful configuration call may therefore notify even when values are
+unchanged. Adding a policy version does not necessarily activate it.
+Provider events concern configuration, not changes in data queried from
+external sources. Events do not contain policy XML or provider configuration,
+which could include sensitive information.
+
+No successful-change event is emitted for a rejected operation, conflicting
+policy version, or not-found deletion. Domain creation, domain metadata and
+repository-limit updates are outside this initial contract. Automatic version
+rolling does not produce separate deletion notifications. Direct filesystem
+changes and synchronization are not intercepted by the API hooks.
+
+## Delivery and downstream recovery
+
+The callback is best effort, with no retries or transactional outbox in the
+server. A crash after a policy is persisted but before the event is recorded can
+lose a notification. A callback failure does not roll back the policy.
+
+External integrations may provide durable event queues, asynchronous publishing,
+retry and duplicate handling. Such a queue is not atomic with policy storage.
+Startup and periodic reconciliation against the PAP can recover the current
+state after missed notifications, but not necessarily every intermediate change.
+This entails a period of possible staleness, including delayed revocations.
+
+Each embedded PDP instance must recover a coherent set of policies, root
+selection and relevant provider configuration. Provider implementation JARs must
+already be available to that instance. Building and safely replacing the embedded
+PDP, instance fan-out and reconciliation are responsibilities of the downstream
+integration, not features introduced by this extension point.
diff --git a/rest-service/src/main/java/org/ow2/authzforce/rest/service/PapChangeEvent.java b/rest-service/src/main/java/org/ow2/authzforce/rest/service/PapChangeEvent.java
new file mode 100644
index 0000000..be6cb9d
--- /dev/null
+++ b/rest-service/src/main/java/org/ow2/authzforce/rest/service/PapChangeEvent.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright (C) 2012-2026 THALES.
+ *
+ * This file is part of AuthzForce CE.
+ *
+ * AuthzForce CE is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * AuthzForce CE is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with AuthzForce CE. If not, see .
+ */
+package org.ow2.authzforce.rest.service;
+
+import java.time.Instant;
+import java.util.Objects;
+import java.util.UUID;
+
+/**
+ * Successful PAP API operation; does not imply activation of an added policy version.
+ * Identifiers and timestamps are not a domain revision or an ordering guarantee.
+ * @param eventId unique notification identifier
+ * @param timestamp notification creation time
+ * @param domainId affected domain
+ * @param operation successful operation
+ * @param policyId affected policy, or null for domain/configuration operations
+ * @param policyVersion affected version, or null for whole-policy/domain/configuration operations
+ */
+public record PapChangeEvent(UUID eventId, Instant timestamp, String domainId,
+ Operation operation, String policyId, String policyVersion)
+{
+ /** Supported PAP operations. Automatic rolling and direct file edits are not separate events. */
+ public enum Operation
+ {
+ /** A policy version was added. */
+ POLICY_VERSION_ADDED,
+ /** A specific policy version was removed. */
+ POLICY_VERSION_DELETED,
+ /** A policy and its versions were removed. */
+ POLICY_DELETED,
+ /** PDP configuration, including root policy selection, was updated. */
+ PDP_CONFIGURATION_CHANGED,
+ /** Attribute-provider configuration was updated, not its external source data. */
+ ATTRIBUTE_PROVIDERS_CHANGED,
+ /** A domain was removed. */
+ DOMAIN_DELETED
+ }
+
+ /** Validate mandatory metadata and the operation-specific policy identifiers. */
+ public PapChangeEvent
+ {
+ Objects.requireNonNull(eventId, "eventId");
+ Objects.requireNonNull(timestamp, "timestamp");
+ Objects.requireNonNull(domainId, "domainId");
+ Objects.requireNonNull(operation, "operation");
+ switch (operation)
+ {
+ case POLICY_VERSION_ADDED, POLICY_VERSION_DELETED -> {
+ Objects.requireNonNull(policyId, "policyId");
+ Objects.requireNonNull(policyVersion, "policyVersion");
+ }
+ case POLICY_DELETED -> {
+ Objects.requireNonNull(policyId, "policyId");
+ if (policyVersion != null) throw new IllegalArgumentException("Whole-policy event cannot specify a version");
+ }
+ default -> {
+ if (policyId != null || policyVersion != null)
+ throw new IllegalArgumentException("Configuration/domain event cannot specify a policy");
+ }
+ }
+ }
+}
+
diff --git a/rest-service/src/main/java/org/ow2/authzforce/rest/service/PapChangeListener.java b/rest-service/src/main/java/org/ow2/authzforce/rest/service/PapChangeListener.java
new file mode 100644
index 0000000..0c9f53b
--- /dev/null
+++ b/rest-service/src/main/java/org/ow2/authzforce/rest/service/PapChangeListener.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2012-2026 THALES.
+ *
+ * This file is part of AuthzForce CE.
+ *
+ * AuthzForce CE is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * AuthzForce CE is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with AuthzForce CE. If not, see .
+ */
+package org.ow2.authzforce.rest.service;
+
+/**
+ * Optional notification of successful PAP API operations.
+ * Implementations must be thread-safe and return promptly: callbacks run synchronously
+ * on request threads and may run concurrently or arrive out of mutation order.
+ * Runtime exceptions are logged without changing the successful PAP response.
+ * Delivery is best effort, not transactional with policy persistence.
+ */
+@FunctionalInterface
+public interface PapChangeListener
+{
+ /**
+ * Handle a successful PAP operation.
+ * @param event immutable operation metadata (no policy or provider configuration payload)
+ */
+ void papChanged(PapChangeEvent event);
+}
+
diff --git a/rest-service/src/main/java/org/ow2/authzforce/rest/service/jaxrs/DomainResourceImpl.java b/rest-service/src/main/java/org/ow2/authzforce/rest/service/jaxrs/DomainResourceImpl.java
index 36c1e08..b3cc5af 100644
--- a/rest-service/src/main/java/org/ow2/authzforce/rest/service/jaxrs/DomainResourceImpl.java
+++ b/rest-service/src/main/java/org/ow2/authzforce/rest/service/jaxrs/DomainResourceImpl.java
@@ -33,6 +33,7 @@
import org.ow2.authzforce.xmlns.pdp.ext.AbstractAttributeProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.ow2.authzforce.rest.service.PapChangeEvent.Operation;
import org.w3._2005.atom.Link;
import org.w3._2005.atom.Relation;
@@ -128,14 +129,29 @@ public PolicyDaoClient.Factory ge
private final String domainId;
private final DAO domainDAO;
+ private final PapChangeNotifier notifier;
private DomainResourceImpl(final String domainId, final Builder domainDaoBuilder) throws IOException
{
assert domainDaoBuilder != null;
this.domainId = domainId;
this.domainDAO = domainDaoBuilder.build();
+ this.notifier = PapChangeNotifier.NONE;
}
+ private DomainResourceImpl(String domainId, DAO domainDAO, PapChangeNotifier notifier)
+ {
+ this.domainId = domainId;
+ this.domainDAO = domainDAO;
+ this.notifier = notifier;
+ }
+
+ // Keep DAO-cached clients untouched: request views carry an immutable listener context.
+ DomainResourceImpl withNotifier(PapChangeNotifier notifier)
+ {
+ return new DomainResourceImpl<>(domainId, domainDAO, notifier);
+ }
+
@Override
public Domain getDomain()
{
@@ -227,6 +243,7 @@ public DomainProperties deleteDomain()
throw NOT_FOUND_EXCEPTION;
}
+ notifier.notifyChange(Operation.DOMAIN_DELETED, null, null);
return new DomainProperties(props.getDescription(), props.getExternalId());
}
@@ -427,6 +444,7 @@ public AttributeProviders updateAttributeProviderList(final AttributeProviders a
throw new BadRequestException(e);
}
+ notifier.notifyChange(Operation.ATTRIBUTE_PROVIDERS_CHANGED, null, null);
return attributeproviders;
}
@@ -472,6 +490,7 @@ public Link addPolicy(final PolicySet policy)
throw ADD_POLICY_CONFLICT_EXCEPTION;
}
+ notifier.notifyChange(Operation.POLICY_VERSION_ADDED, policy.getPolicySetId(), policy.getVersion());
// Policy ID is xs:anyURI, therefore may contain invalid characters for
// URL paths -> needs escaping to be used as URL path segment
final String policyIdUrlPathSegment = URL_PATH_SEGMENT_ESCAPER.escape(policy.getPolicySetId());
@@ -486,13 +505,13 @@ public PolicyResource getPolicyResource(final String policyId)
throw INVALID_ARG_BAD_REQUEST_EXCEPTION;
}
- final PolicyResource policyRes = domainDAO.getPolicyDaoClient(policyId);
+ final PolicyResourceImpl policyRes = domainDAO.getPolicyDaoClient(policyId);
if (policyRes == null)
{
throw NOT_FOUND_EXCEPTION;
}
- return policyRes;
+ return notifier == PapChangeNotifier.NONE ? policyRes : policyRes.withNotifier(notifier);
}
@Override
@@ -591,6 +610,7 @@ public PdpProperties updateOtherPdpProperties(final PdpPropertiesUpdate properti
throw new BadRequestException(e);
}
+ notifier.notifyChange(Operation.PDP_CONFIGURATION_CHANGED, null, null);
final GregorianCalendar cal = new GregorianCalendar(UTC_TZ);
cal.setTimeInMillis(allProps.getLastModified());
@@ -666,4 +686,4 @@ public PrpProperties getOtherPrpProperties()
return new PrpProperties(mpc, mvc, props.isVersionRollingEnabled());
}
-}
\ No newline at end of file
+}
diff --git a/rest-service/src/main/java/org/ow2/authzforce/rest/service/jaxrs/DomainsResourceImpl.java b/rest-service/src/main/java/org/ow2/authzforce/rest/service/jaxrs/DomainsResourceImpl.java
index 8b975b7..73bf535 100644
--- a/rest-service/src/main/java/org/ow2/authzforce/rest/service/jaxrs/DomainsResourceImpl.java
+++ b/rest-service/src/main/java/org/ow2/authzforce/rest/service/jaxrs/DomainsResourceImpl.java
@@ -33,6 +33,7 @@
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.ow2.authzforce.core.pap.api.dao.DomainsDao;
+import org.ow2.authzforce.rest.service.PapChangeListener;
import org.ow2.authzforce.rest.api.jaxrs.DomainResource;
import org.ow2.authzforce.rest.api.jaxrs.DomainsResource;
import org.ow2.authzforce.rest.api.xmlns.DomainProperties;
@@ -59,6 +60,8 @@ public class DomainsResourceImpl implements DomainsResource
private final String anyResourceId;
+ private final PapChangeListener papChangeListener;
+
/**
* Constructor
*
@@ -69,13 +72,28 @@ public class DomainsResourceImpl implements DomainsResource
* @param anyResourceId
* identifier for "any resource" (access to any one)
*/
- @SuppressFBWarnings(value="EI_EXPOSE_REP2")
@ConstructorProperties({ "domainsDao", "authorizedResourceAttribute", "anyResourceId" })
public DomainsResourceImpl(final DomainsDao> domainsDao, final String authorizedResourceAttribute, final String anyResourceId)
+ {
+ this(domainsDao, authorizedResourceAttribute, anyResourceId, null);
+ }
+
+ /**
+ * Construct the resource with optional PAP notifications.
+ * @param domainsDao domain repository
+ * @param authorizedResourceAttribute authorized resource request attribute
+ * @param anyResourceId wildcard resource identifier
+ * @param papChangeListener optional listener; null disables notifications
+ */
+ @SuppressFBWarnings(value="EI_EXPOSE_REP2")
+ @ConstructorProperties({ "domainsDao", "authorizedResourceAttribute", "anyResourceId", "papChangeListener" })
+ public DomainsResourceImpl(final DomainsDao> domainsDao, final String authorizedResourceAttribute,
+ final String anyResourceId, final PapChangeListener papChangeListener)
{
this.domainRepo = domainsDao;
this.authorizedResourceAttrId = authorizedResourceAttribute;
this.anyResourceId = anyResourceId;
+ this.papChangeListener = papChangeListener;
}
/*
@@ -214,7 +232,7 @@ public DomainResource getDomainResource(final String domainId)
throw INVALID_ARG_BAD_REQUEST_EXCEPTION;
}
- final DomainResource domainRes;
+ final DomainResourceImpl> domainRes;
try
{
domainRes = domainRepo.getDomainDaoClient(domainId);
@@ -229,6 +247,7 @@ public DomainResource getDomainResource(final String domainId)
throw NOT_FOUND_EXCEPTION;
}
- return domainRes;
+ return papChangeListener == null ? domainRes
+ : domainRes.withNotifier(new PapChangeNotifier(domainId, papChangeListener));
}
}
diff --git a/rest-service/src/main/java/org/ow2/authzforce/rest/service/jaxrs/PapChangeNotifier.java b/rest-service/src/main/java/org/ow2/authzforce/rest/service/jaxrs/PapChangeNotifier.java
new file mode 100644
index 0000000..6a9f226
--- /dev/null
+++ b/rest-service/src/main/java/org/ow2/authzforce/rest/service/jaxrs/PapChangeNotifier.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2012-2026 THALES.
+ *
+ * This file is part of AuthzForce CE.
+ *
+ * AuthzForce CE is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * AuthzForce CE is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with AuthzForce CE. If not, see .
+ */
+package org.ow2.authzforce.rest.service.jaxrs;
+
+import java.time.Instant;
+import java.util.UUID;
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+import org.ow2.authzforce.rest.service.PapChangeEvent;
+import org.ow2.authzforce.rest.service.PapChangeListener;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Immutable domain-scoped notification context; never attached mutably to cached DAO clients. */
+final class PapChangeNotifier
+{
+ static final PapChangeNotifier NONE = new PapChangeNotifier(null, null);
+ private static final Logger LOGGER = LoggerFactory.getLogger(PapChangeNotifier.class);
+ private final String domainId;
+ private final PapChangeListener listener;
+
+ PapChangeNotifier(String domainId, PapChangeListener listener)
+ {
+ this.domainId = domainId;
+ this.listener = listener;
+ }
+
+ @SuppressFBWarnings(value = "CRLF_INJECTION_LOGS",
+ justification = "The only formatted metadata is a server-generated UUID; the exception is logged as a Throwable.")
+ void notifyChange(PapChangeEvent.Operation operation, String policyId, String version)
+ {
+ if (listener == null) return;
+ final PapChangeEvent event = new PapChangeEvent(UUID.randomUUID(), Instant.now(), domainId, operation, policyId, version);
+ try
+ {
+ listener.papChanged(event);
+ }
+ catch (RuntimeException e)
+ {
+ LOGGER.error("PAP change listener failed for event {}", event.eventId(), e);
+ }
+ }
+}
+
diff --git a/rest-service/src/main/java/org/ow2/authzforce/rest/service/jaxrs/PolicyResourceImpl.java b/rest-service/src/main/java/org/ow2/authzforce/rest/service/jaxrs/PolicyResourceImpl.java
index adbd508..3a606eb 100644
--- a/rest-service/src/main/java/org/ow2/authzforce/rest/service/jaxrs/PolicyResourceImpl.java
+++ b/rest-service/src/main/java/org/ow2/authzforce/rest/service/jaxrs/PolicyResourceImpl.java
@@ -32,6 +32,7 @@
import org.ow2.authzforce.core.pap.api.dao.PolicyVersionDaoClient;
import org.ow2.authzforce.core.pdp.api.policy.PolicyVersion;
import org.ow2.authzforce.rest.api.jaxrs.PolicyResource;
+import org.ow2.authzforce.rest.service.PapChangeEvent.Operation;
import org.ow2.authzforce.rest.api.jaxrs.PolicyVersionResource;
import org.ow2.authzforce.rest.api.xmlns.Resources;
import org.w3._2005.atom.Link;
@@ -83,14 +84,27 @@ public PolicyVersionDaoClient.Factory getVersionDaoCl
private final DomainDao domainDAO;
private final String policyId;
+ private final PapChangeNotifier notifier;
private PolicyResourceImpl(final String policyId, final DomainDao domainDAO)
+ {
+ this(policyId, domainDAO, PapChangeNotifier.NONE);
+ }
+
+ private PolicyResourceImpl(final String policyId, final DomainDao domainDAO,
+ PapChangeNotifier notifier)
{
assert domainDAO != null;
this.policyId = policyId;
this.domainDAO = domainDAO;
+ this.notifier = notifier;
}
+ PolicyResourceImpl withNotifier(PapChangeNotifier notifier)
+ {
+ return new PolicyResourceImpl(policyId, domainDAO, notifier);
+ }
+
private static Resources getVersionResources(final NavigableSet versions)
{
// versions expected to be ordered from latest to oldest
@@ -126,6 +140,7 @@ public Resources deletePolicy()
throw NOT_FOUND_EXCEPTION;
}
+ notifier.notifyChange(Operation.POLICY_DELETED, policyId, null);
return getVersionResources(removedPolicyVersions);
}
@@ -182,13 +197,13 @@ public PolicyVersionResource getPolicyVersionResource(final String version)
}
}
- final PolicyVersionResource versionResource = domainDAO.getVersionDaoClient(policyId, policyVersion);
+ final PolicyVersionResourceImpl versionResource = domainDAO.getVersionDaoClient(policyId, policyVersion);
if (versionResource == null)
{
throw NOT_FOUND_EXCEPTION;
}
- return versionResource;
+ return notifier == PapChangeNotifier.NONE ? versionResource : versionResource.withNotifier(notifier);
}
-}
\ No newline at end of file
+}
diff --git a/rest-service/src/main/java/org/ow2/authzforce/rest/service/jaxrs/PolicyVersionResourceImpl.java b/rest-service/src/main/java/org/ow2/authzforce/rest/service/jaxrs/PolicyVersionResourceImpl.java
index 823513e..de0f10c 100644
--- a/rest-service/src/main/java/org/ow2/authzforce/rest/service/jaxrs/PolicyVersionResourceImpl.java
+++ b/rest-service/src/main/java/org/ow2/authzforce/rest/service/jaxrs/PolicyVersionResourceImpl.java
@@ -24,6 +24,7 @@
import org.ow2.authzforce.core.pap.api.dao.PolicyVersionDaoClient;
import org.ow2.authzforce.core.pdp.api.policy.PolicyVersion;
import org.ow2.authzforce.rest.api.jaxrs.PolicyVersionResource;
+import org.ow2.authzforce.rest.service.PapChangeEvent.Operation;
import jakarta.ws.rs.InternalServerErrorException;
import jakarta.ws.rs.NotFoundException;
@@ -73,15 +74,28 @@ public final class PolicyVersionResourceImpl implements PolicyVersionDaoClient,
private final DomainDao, ?> domainDAO;
private final String policyId;
+ private final PapChangeNotifier notifier;
private PolicyVersionResourceImpl(final String policyId, final PolicyVersion versionId, final DomainDao, ?> domainDAO)
+ {
+ this(policyId, versionId, domainDAO, PapChangeNotifier.NONE);
+ }
+
+ private PolicyVersionResourceImpl(final String policyId, final PolicyVersion versionId, final DomainDao, ?> domainDAO,
+ PapChangeNotifier notifier)
{
assert versionId != null && domainDAO != null;
this.policyId = policyId;
this.versionId = versionId;
this.domainDAO = domainDAO;
+ this.notifier = notifier;
}
+ PolicyVersionResourceImpl withNotifier(PapChangeNotifier notifier)
+ {
+ return new PolicyVersionResourceImpl(policyId, versionId, domainDAO, notifier);
+ }
+
@Override
public PolicySet getPolicyVersion()
{
@@ -128,8 +142,9 @@ public PolicySet deletePolicyVersion() throws IllegalArgumentException
// This does not work, PhaseInterceptorChain.getCurrentMessage() is replaced in response, so JAXBElementProvider does not have access to it
//PhaseInterceptorChain.getCurrentMessage().put(DomainResourceImpl.XML_NS_CONTEXTS_CXF_MESSAGE_CONTEXT_PROPERTY_NAME, policyVersion.getXPathNamespaceContexts());
+ notifier.notifyChange(Operation.POLICY_VERSION_DELETED, policyId, versionId.toString());
OUTPUT_POLICY_XPATH_NAMESPACE_CONTEXT.get().putAll(deletedPolicyVersion.getXPathNamespaceContexts());
return deletedPolicyVersion.toXacml();
}
-}
\ No newline at end of file
+}
diff --git a/webapp/pom.xml b/webapp/pom.xml
index c061b06..e92ef7f 100644
--- a/webapp/pom.xml
+++ b/webapp/pom.xml
@@ -334,6 +334,7 @@
+ src/test/suites/pap_notifications.xmlsrc/test/suites/xml_pdp_only.xml
diff --git a/webapp/src/main/java/org/ow2/authzforce/webapp/PapChangeListenerFactoryBean.java b/webapp/src/main/java/org/ow2/authzforce/webapp/PapChangeListenerFactoryBean.java
new file mode 100644
index 0000000..1cab95c
--- /dev/null
+++ b/webapp/src/main/java/org/ow2/authzforce/webapp/PapChangeListenerFactoryBean.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2012-2026 THALES.
+ *
+ * This file is part of AuthzForce CE.
+ *
+ * AuthzForce CE is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * AuthzForce CE is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with AuthzForce CE. If not, see .
+ */
+package org.ow2.authzforce.webapp;
+
+import org.ow2.authzforce.rest.service.PapChangeListener;
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.BeanClassLoaderAware;
+import org.springframework.beans.factory.DisposableBean;
+import org.springframework.beans.factory.FactoryBean;
+import org.springframework.beans.factory.InitializingBean;
+import org.springframework.util.ClassUtils;
+
+/**
+ * Creates an optional listener using the web application's classloader.
+ * Empty configuration produces a null listener; invalid configuration fails startup.
+ * Listeners must expose a public no-argument constructor.
+ */
+public final class PapChangeListenerFactoryBean
+ implements FactoryBean, BeanClassLoaderAware, InitializingBean, DisposableBean
+{
+ private final String className;
+ private ClassLoader classLoader = ClassUtils.getDefaultClassLoader();
+ private PapChangeListener listener;
+
+ /**
+ * Configure the optional extension class.
+ * @param className fully qualified listener class name, or empty to disable notifications
+ */
+ public PapChangeListenerFactoryBean(String className)
+ {
+ this.className = className == null ? "" : className.trim();
+ }
+
+ @Override
+ public void setBeanClassLoader(ClassLoader classLoader)
+ {
+ this.classLoader = classLoader;
+ }
+
+ @Override
+ public void afterPropertiesSet() throws Exception
+ {
+ if (!className.isEmpty())
+ {
+ final Class extends PapChangeListener> type =
+ ClassUtils.forName(className, classLoader).asSubclass(PapChangeListener.class);
+ listener = BeanUtils.instantiateClass(type.getConstructor());
+ }
+ }
+
+ @Override
+ public PapChangeListener getObject()
+ {
+ return listener;
+ }
+
+ @Override
+ public Class> getObjectType()
+ {
+ return PapChangeListener.class;
+ }
+
+ @Override
+ public boolean isSingleton()
+ {
+ return true;
+ }
+
+ @Override
+ public void destroy() throws Exception
+ {
+ if (listener instanceof AutoCloseable closeable) closeable.close();
+ }
+}
+
diff --git a/webapp/src/main/webapp/WEB-INF/beans.xml b/webapp/src/main/webapp/WEB-INF/beans.xml
index 55ed949..5130c34 100644
--- a/webapp/src/main/webapp/WEB-INF/beans.xml
+++ b/webapp/src/main/webapp/WEB-INF/beans.xml
@@ -81,9 +81,17 @@
+
+
+
+
+
+
+
diff --git a/webapp/src/test/java/org/ow2/authzforce/rest/service/jaxrs/PapChangeListenerTest.java b/webapp/src/test/java/org/ow2/authzforce/rest/service/jaxrs/PapChangeListenerTest.java
new file mode 100644
index 0000000..68fc344
--- /dev/null
+++ b/webapp/src/test/java/org/ow2/authzforce/rest/service/jaxrs/PapChangeListenerTest.java
@@ -0,0 +1,224 @@
+/*
+ * Copyright (C) 2012-2026 THALES.
+ *
+ * This file is part of AuthzForce CE.
+ *
+ * AuthzForce CE is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * AuthzForce CE is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with AuthzForce CE. If not, see .
+ */
+package org.ow2.authzforce.rest.service.jaxrs;
+
+import java.io.IOException;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Proxy;
+import java.util.*;
+import java.util.concurrent.*;
+import jakarta.ws.rs.BadRequestException;
+import jakarta.ws.rs.ClientErrorException;
+import jakarta.ws.rs.InternalServerErrorException;
+import jakarta.ws.rs.NotFoundException;
+import oasis.names.tc.xacml._3_0.core.schema.wd_17.PolicySet;
+import oasis.names.tc.xacml._3_0.core.schema.wd_17.Target;
+import org.apache.cxf.message.MessageImpl;
+import org.apache.cxf.phase.PhaseInterceptorChain;
+import org.ow2.authzforce.core.pap.api.dao.*;
+import org.ow2.authzforce.core.pdp.api.policy.PolicyVersion;
+import org.ow2.authzforce.rest.api.jaxrs.DomainResource;
+import org.ow2.authzforce.rest.api.xmlns.AttributeProviders;
+import org.ow2.authzforce.rest.api.xmlns.PdpPropertiesUpdate;
+import org.ow2.authzforce.rest.service.*;
+import org.testng.annotations.Test;
+import static org.testng.Assert.*;
+
+/** Tests API hooks independently of filesystem storage using the existing DAO contracts. */
+public class PapChangeListenerTest
+{
+ @SuppressWarnings("unchecked")
+ private static T proxy(Class type, InvocationHandler handler)
+ {
+ return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class>[]{type}, handler);
+ }
+
+ private static final class Fixture
+ {
+ final PolicySet policy = new PolicySet(null, null, null, new Target(null), null, null, null,
+ "policy", "1.0", "urn:oasis:names:tc:xacml:3.0:policy-combining-algorithm:deny-unless-permit", null);
+ final AuthzPolicy authzPolicy = new JaxbXacmlAuthzPolicy(policy, com.google.common.collect.ImmutableMap.of());
+ boolean missing;
+ boolean conflict;
+ boolean invalid;
+ boolean ioFailure;
+ final java.util.concurrent.atomic.AtomicInteger completedMutations = new java.util.concurrent.atomic.AtomicInteger();
+ final DomainDao dao;
+ final DomainResourceImpl> cached;
+ final DomainsDao> repository;
+
+ @SuppressWarnings("unchecked")
+ Fixture() throws IOException
+ {
+ dao = proxy(DomainDao.class, (instance, method, args) -> {
+ String name = method.getName();
+ if (name.equals("getPolicyDaoClient"))
+ return PolicyResourceImpl.FACTORY.getInstance((String) args[0], dao());
+ if (name.equals("getVersionDaoClient"))
+ return PolicyVersionResourceImpl.FACTORY.getInstance((String) args[0], (PolicyVersion) args[1], dao());
+ if (name.equals("getLatestPolicyVersionId")) return new PolicyVersion("1.0");
+ if (invalid) throw new IllegalArgumentException("Rejected mutation");
+ if (ioFailure) throw new IOException("Storage failure");
+ if (name.equals("addPolicy")) {
+ if (conflict) return authzPolicy;
+ completedMutations.incrementAndGet();
+ return null;
+ }
+ if (name.equals("removePolicy")) {
+ if (missing) return new TreeSet();
+ completedMutations.incrementAndGet();
+ return new TreeSet<>(List.of(new PolicyVersion("1.0")));
+ }
+ if (name.equals("removePolicyVersion")) {
+ if (missing) return null;
+ completedMutations.incrementAndGet();
+ return authzPolicy;
+ }
+ if (name.equals("removeDomain")) {
+ if (missing) return null;
+ completedMutations.incrementAndGet();
+ return proxy(ReadableDomainProperties.class, (p, m, a) -> null);
+ }
+ if (name.equals("setAttributeProviders")) {
+ completedMutations.incrementAndGet();
+ return args[0];
+ }
+ if (name.equals("setOtherPdpProperties")) {
+ completedMutations.incrementAndGet();
+ return proxy(ReadablePdpProperties.class, (p, m, a) -> switch (m.getName()) {
+ case "getLastModified" -> 0L;
+ case "getFeatures", "getApplicableRefPolicyRefs" -> Collections.emptyList();
+ default -> null;
+ });
+ }
+ throw new UnsupportedOperationException(name);
+ });
+ cached = new DomainResourceImpl.Factory>()
+ .getInstance("domain", () -> dao);
+ repository = proxy(DomainsDao.class, (p, m, a) -> {
+ if (m.getName().equals("getDomainDaoClient")) return cached;
+ throw new UnsupportedOperationException(m.getName());
+ });
+ }
+
+ private DomainDao dao() { return dao; }
+
+ DomainResource resource(String domain, PapChangeListener listener)
+ {
+ return new DomainsResourceImpl(repository, "authorizedResource", null, listener).getDomainResource(domain);
+ }
+ }
+
+ private static void add(DomainResource resource, PolicySet policy)
+ {
+ PhaseInterceptorChain chain = new PhaseInterceptorChain(
+ new TreeSet<>(List.of(new org.apache.cxf.phase.Phase("invoke", 1))));
+ java.util.concurrent.atomic.AtomicReference failure = new java.util.concurrent.atomic.AtomicReference<>();
+ chain.add(new org.apache.cxf.phase.AbstractPhaseInterceptor("invoke") {
+ @Override public void handleMessage(org.apache.cxf.message.Message message) {
+ try { resource.getPapResource().getPoliciesResource().addPolicy(policy); }
+ catch (RuntimeException e) { failure.set(e); }
+ }
+ });
+ chain.doIntercept(new MessageImpl());
+ if (failure.get() != null) throw failure.get();
+ }
+
+ @Test
+ public void emitsAllSupportedOperationsAfterSuccess() throws Exception
+ {
+ Fixture f = new Fixture();
+ List events = new ArrayList<>();
+ DomainResource resource = f.resource("domain", event -> {
+ assertEquals(f.completedMutations.get(), events.size() + 1);
+ events.add(event);
+ });
+ add(resource, f.policy);
+ resource.getPapResource().getPoliciesResource().getPolicyResource("policy").getPolicyVersionResource("latest").deletePolicyVersion();
+ resource.getPapResource().getPoliciesResource().getPolicyResource("policy").deletePolicy();
+ resource.getPapResource().getAttributeProvidersResource().updateAttributeProviderList(new AttributeProviders(Collections.emptyList()));
+ resource.getPapResource().getPdpPropertiesResource().updateOtherPdpProperties(new PdpPropertiesUpdate(Collections.emptyList(), null));
+ resource.deleteDomain();
+ assertEquals(events.stream().map(PapChangeEvent::operation).toList(), List.of(
+ PapChangeEvent.Operation.POLICY_VERSION_ADDED, PapChangeEvent.Operation.POLICY_VERSION_DELETED,
+ PapChangeEvent.Operation.POLICY_DELETED, PapChangeEvent.Operation.ATTRIBUTE_PROVIDERS_CHANGED,
+ PapChangeEvent.Operation.PDP_CONFIGURATION_CHANGED, PapChangeEvent.Operation.DOMAIN_DELETED));
+ assertEquals(events.get(1).policyVersion(), "1.0");
+ assertEquals(events.get(2).policyId(), "policy");
+ assertNull(events.get(2).policyVersion());
+ assertNull(events.get(3).policyId());
+ assertEquals(events.stream().map(PapChangeEvent::eventId).distinct().count(), 6L);
+ assertTrue(events.stream().allMatch(e -> e.domainId().equals("domain") && e.timestamp() != null));
+ }
+
+ @Test
+ public void rejectedOperationsDoNotNotify() throws Exception
+ {
+ Fixture f = new Fixture();
+ List events = new ArrayList<>();
+ DomainResource r = f.resource("domain", events::add);
+ f.conflict = true;
+ expectThrows(ClientErrorException.class, () -> add(r, f.policy));
+ f.missing = true;
+ expectThrows(NotFoundException.class, () -> r.getPapResource().getPoliciesResource().getPolicyResource("policy").deletePolicy());
+ expectThrows(NotFoundException.class, () -> r.getPapResource().getPoliciesResource().getPolicyResource("policy").getPolicyVersionResource("1.0").deletePolicyVersion());
+ expectThrows(NotFoundException.class, r::deleteDomain);
+ f.invalid = true;
+ expectThrows(BadRequestException.class, () -> r.getPapResource().getAttributeProvidersResource().updateAttributeProviderList(new AttributeProviders(Collections.emptyList())));
+ expectThrows(BadRequestException.class, () -> r.getPapResource().getPdpPropertiesResource().updateOtherPdpProperties(new PdpPropertiesUpdate(Collections.emptyList(), null)));
+ f.invalid = false;
+ f.ioFailure = true;
+ expectThrows(InternalServerErrorException.class, () -> add(r, f.policy));
+ assertTrue(events.isEmpty());
+ }
+
+ @Test
+ public void listenerFailureDoesNotChangeSuccessAndCachedClientsStayUnchanged() throws Exception
+ {
+ Fixture f = new Fixture();
+ DomainResource r = f.resource("domain", e -> { throw new IllegalStateException("Listener unavailable"); });
+ r.getPapResource().getPoliciesResource().getPolicyResource("policy").deletePolicy();
+ assertEquals(f.completedMutations.get(), 1);
+ assertSame(new DomainsResourceImpl(f.repository, "authorizedResource", null).getDomainResource("domain"), f.cached);
+ f.cached.getPolicyResource("policy").deletePolicy();
+ assertEquals(f.completedMutations.get(), 2);
+ }
+
+ @Test
+ public void concurrentViewsKeepTheirOwnListenerAndDomain() throws Exception
+ {
+ Fixture f = new Fixture();
+ List first = new CopyOnWriteArrayList<>();
+ List second = new CopyOnWriteArrayList<>();
+ DomainResource a = f.resource("a", first::add);
+ DomainResource b = f.resource("b", second::add);
+ ExecutorService pool = Executors.newFixedThreadPool(2);
+ try {
+ List> tasks = List.of(
+ () -> { for (int i = 0; i < 30; i++) a.getPapResource().getPoliciesResource().getPolicyResource("policy").deletePolicy(); return null; },
+ () -> { for (int i = 0; i < 30; i++) b.getPapResource().getPoliciesResource().getPolicyResource("policy").getPolicyVersionResource("1.0").deletePolicyVersion(); return null; });
+ for (Future result : pool.invokeAll(tasks)) result.get();
+ } finally { pool.shutdownNow(); }
+ assertEquals(first.size(), 30);
+ assertEquals(second.size(), 30);
+ assertTrue(first.stream().allMatch(e -> e.domainId().equals("a")));
+ assertTrue(second.stream().allMatch(e -> e.domainId().equals("b")));
+ }
+}
+
diff --git a/webapp/src/test/java/org/ow2/authzforce/webapp/test/PapChangeListenerConfigurationTest.java b/webapp/src/test/java/org/ow2/authzforce/webapp/test/PapChangeListenerConfigurationTest.java
new file mode 100644
index 0000000..f61624a
--- /dev/null
+++ b/webapp/src/test/java/org/ow2/authzforce/webapp/test/PapChangeListenerConfigurationTest.java
@@ -0,0 +1,135 @@
+/*
+ * Copyright (C) 2012-2026 THALES.
+ *
+ * This file is part of AuthzForce CE.
+ *
+ * AuthzForce CE is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * AuthzForce CE is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with AuthzForce CE. If not, see .
+ */
+package org.ow2.authzforce.webapp.test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import javax.xml.parsers.DocumentBuilderFactory;
+import org.ow2.authzforce.rest.service.PapChangeEvent;
+import org.ow2.authzforce.rest.service.PapChangeListener;
+import org.ow2.authzforce.rest.service.jaxrs.DomainsResourceImpl;
+import org.springframework.beans.factory.support.RootBeanDefinition;
+import org.springframework.beans.factory.xml.XmlBeanDefinitionReader;
+import org.springframework.context.support.GenericApplicationContext;
+import org.springframework.jndi.JndiTemplate;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+import org.testng.annotations.Test;
+import static org.testng.Assert.*;
+
+/** Exercises the actual listener/JNDI/resource bean declarations from the production XML. */
+public class PapChangeListenerConfigurationTest
+{
+ /** Example separately loadable extension. */
+ public static final class RecordingListener implements PapChangeListener, AutoCloseable
+ {
+ static final AtomicInteger CLOSED = new AtomicInteger();
+ @Override public void papChanged(PapChangeEvent event) { }
+ @Override public void close() { CLOSED.incrementAndGet(); }
+ }
+
+ /** Constructor failure must prevent startup. */
+ public static final class BrokenListener implements PapChangeListener
+ {
+ public BrokenListener() { throw new IllegalStateException("Cannot initialize extension"); }
+ @Override public void papChanged(PapChangeEvent event) { }
+ }
+
+ private static GenericApplicationContext context(String configuredClass) throws Exception
+ {
+ DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+ factory.setNamespaceAware(true);
+ Document doc = factory.newDocumentBuilder().parse("src/main/webapp/WEB-INF/beans.xml");
+ Element root = doc.getDocumentElement();
+ List remove = new ArrayList<>();
+ for (Node node = root.getFirstChild(); node != null; node = node.getNextSibling())
+ {
+ if (!(node instanceof Element element) ||
+ !List.of("jndi_papChangeListenerClass", "papChangeListener", "domainsResourceBean").contains(element.getAttribute("id")))
+ remove.add(node);
+ }
+ remove.forEach(root::removeChild);
+ GenericApplicationContext context = new GenericApplicationContext();
+ // Keep the real JNDI lookup and Spring wiring, substituting only its external naming service.
+ if (configuredClass != null)
+ {
+ context.getBeanFactory().registerSingleton("testJndiTemplate", new JndiTemplate() {
+ @Override public Object lookup(String name) {
+ assertTrue(name.endsWith("org.ow2.authzforce.rest.service.PapChangeListener.class"));
+ return configuredClass;
+ }
+ });
+ }
+ // No storage is needed to verify constructor injection. Supply an explicit null bean instead.
+ RootBeanDefinition nullDao = new RootBeanDefinition(NullDaoFactory.class);
+ context.registerBeanDefinition("domainsDaoBean", nullDao);
+ new XmlBeanDefinitionReader(context).registerBeanDefinitions(doc, new org.springframework.core.io.DescriptiveResource("Production PAP listener wiring"));
+ if (configuredClass != null)
+ context.getBeanDefinition("jndi_papChangeListenerClass").getPropertyValues()
+ .add("jndiTemplate", context.getBeanFactory().getSingleton("testJndiTemplate"));
+ return context;
+ }
+
+ /** Null DAO stand-in: tests startup wiring without initializing persistent domains. */
+ public static final class NullDaoFactory implements org.springframework.beans.factory.FactoryBean