From 1c2e927331f7e3569420f72e2381915696a7cc96 Mon Sep 17 00:00:00 2001 From: "m.faretta" Date: Wed, 21 Oct 2026 12:20:35 +0200 Subject: [PATCH] Add optional PAP change listener configurable via JNDI Notify listeners after successful policy/version changes, PDP and attribute-provider configuration updates, and domain deletion. Support external listener implementations without new dependencies or changes to PDP evaluation and policy persistence. Add configuration documentation and unit/integration tests. --- README.md | 6 + dist/src/webapp-context.xml | 4 +- docs/pap-change-listener.md | 117 +++++++++ .../rest/service/PapChangeEvent.java | 79 ++++++ .../rest/service/PapChangeListener.java | 37 +++ .../service/jaxrs/DomainResourceImpl.java | 26 +- .../service/jaxrs/DomainsResourceImpl.java | 25 +- .../rest/service/jaxrs/PapChangeNotifier.java | 59 +++++ .../service/jaxrs/PolicyResourceImpl.java | 21 +- .../jaxrs/PolicyVersionResourceImpl.java | 17 +- webapp/pom.xml | 1 + .../webapp/PapChangeListenerFactoryBean.java | 91 +++++++ webapp/src/main/webapp/WEB-INF/beans.xml | 8 + .../service/jaxrs/PapChangeListenerTest.java | 224 ++++++++++++++++++ .../PapChangeListenerConfigurationTest.java | 135 +++++++++++ .../test/PapChangeListenerHttpTest.java | 103 ++++++++ .../webapp/test/RestServiceTest.java | 16 +- webapp/src/test/suites/pap_notifications.xml | 15 ++ 18 files changed, 970 insertions(+), 14 deletions(-) create mode 100644 docs/pap-change-listener.md create mode 100644 rest-service/src/main/java/org/ow2/authzforce/rest/service/PapChangeEvent.java create mode 100644 rest-service/src/main/java/org/ow2/authzforce/rest/service/PapChangeListener.java create mode 100644 rest-service/src/main/java/org/ow2/authzforce/rest/service/jaxrs/PapChangeNotifier.java create mode 100644 webapp/src/main/java/org/ow2/authzforce/webapp/PapChangeListenerFactoryBean.java create mode 100644 webapp/src/test/java/org/ow2/authzforce/rest/service/jaxrs/PapChangeListenerTest.java create mode 100644 webapp/src/test/java/org/ow2/authzforce/webapp/test/PapChangeListenerConfigurationTest.java create mode 100644 webapp/src/test/java/org/ow2/authzforce/webapp/test/PapChangeListenerHttpTest.java create mode 100644 webapp/src/test/suites/pap_notifications.xml 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.xml src/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 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 + { + @Override public Object getObject() { return null; } + @Override public Class getObjectType() { return org.ow2.authzforce.core.pap.api.dao.DomainsDao.class; } + @Override public boolean isSingleton() { return true; } + } + + @Test + public void missingAndEmptyJndiConfigurationAllowStartup() throws Exception + { + for (String value : new String[]{null, ""}) + { + try (GenericApplicationContext context = context(value)) { + context.refresh(); + assertNotNull(context.getBean("domainsResourceBean", DomainsResourceImpl.class)); + assertNull(((org.ow2.authzforce.webapp.PapChangeListenerFactoryBean)context.getBean("&papChangeListener")).getObject()); + } + } + } + + @Test + public void configuredListenerIsLoadedAndClosed() throws Exception + { + int before = RecordingListener.CLOSED.get(); + try (GenericApplicationContext context = context(RecordingListener.class.getName())) { + context.refresh(); + assertTrue(context.getBean("papChangeListener") instanceof RecordingListener); + assertNotNull(context.getBean("domainsResourceBean", DomainsResourceImpl.class)); + assertSame(context.getBean("papChangeListener"), context.getBean("papChangeListener")); + } + assertEquals(RecordingListener.CLOSED.get(), before + 1); + } + + @Test + public void invalidListenerConfigurationFailsStartup() throws Exception + { + for (String name : List.of("missing.Listener", String.class.getName(), BrokenListener.class.getName())) { + try (GenericApplicationContext context = context(name)) { + expectThrows(org.springframework.beans.BeansException.class, context::refresh); + } + } + } +} + diff --git a/webapp/src/test/java/org/ow2/authzforce/webapp/test/PapChangeListenerHttpTest.java b/webapp/src/test/java/org/ow2/authzforce/webapp/test/PapChangeListenerHttpTest.java new file mode 100644 index 0000000..3b04dcc --- /dev/null +++ b/webapp/src/test/java/org/ow2/authzforce/webapp/test/PapChangeListenerHttpTest.java @@ -0,0 +1,103 @@ +/* + * 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.List; +import java.util.Collections; +import java.util.concurrent.CopyOnWriteArrayList; +import jakarta.ws.rs.ClientErrorException; +import org.ow2.authzforce.rest.api.jaxrs.DomainResource; +import org.ow2.authzforce.rest.api.xmlns.DomainProperties; +import org.ow2.authzforce.rest.api.xmlns.PdpPropertiesUpdate; +import org.ow2.authzforce.rest.service.PapChangeEvent; +import org.ow2.authzforce.rest.service.PapChangeListener; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.AfterClass; +import org.testng.annotations.Test; +import static org.testng.Assert.*; + +/** Real Tomcat/JNDI/CXF/flat-file integration with an enabled listener. */ +public class PapChangeListenerHttpTest extends RestServiceTest +{ + public static final class Listener implements PapChangeListener + { + static final List EVENTS = new CopyOnWriteArrayList<>(); + static volatile boolean fail; + @Override public void papChanged(PapChangeEvent event) + { + EVENTS.add(event); + if (fail) throw new IllegalStateException("Simulated extension failure"); + } + } + + @Override protected String getPapChangeListenerClassName() { return Listener.class.getName(); } + + @BeforeClass + public void start() throws Exception + { + Listener.EVENTS.clear(); + Listener.fail = false; + startServerAndInitCLient("", ClientType.XML, "", false, 0, false); + } + + @AfterClass(alwaysRun = true) + public void stop() throws Exception + { + Listener.fail = false; + shutdownServer(); + } + + @Test + public void notifiesSuccessfulHttpMutationsAndPreservesResponses() + { + String id = domainsAPIProxyClient.addDomain(new DomainProperties("Listener test", null)).getHref(); + DomainResource domain = domainsAPIProxyClient.getDomainResource(id); + var pap = domain.getPapResource(); + var policies = pap.getPoliciesResource(); + var policy = createDumbXacmlPolicySet("listener-policy", "1.0"); + policies.addPolicy(policy); + int beforeConflict = Listener.EVENTS.size(); + expectThrows(ClientErrorException.class, () -> policies.addPolicy(policy)); + assertEquals(Listener.EVENTS.size(), beforeConflict); + policies.getPolicyResource("listener-policy").getPolicyVersionResource("1.0").deletePolicyVersion(); + policies.addPolicy(createDumbXacmlPolicySet("listener-policy", "2.0")); + policies.getPolicyResource("listener-policy").deletePolicy(); + + // Reapply the existing provider configuration and root selection. + var providers = pap.getAttributeProvidersResource(); + providers.updateAttributeProviderList(providers.getAttributeProviderList()); + var pdp = pap.getPdpPropertiesResource(); + var current = pdp.getOtherPdpProperties(); + pdp.updateOtherPdpProperties(new PdpPropertiesUpdate(Collections.emptyList(), current.getRootPolicyRefExpression())); + Listener.fail = true; + domain.deleteDomain(); + Listener.fail = false; + + assertEquals(Listener.EVENTS.stream().map(PapChangeEvent::operation).toList(), List.of( + PapChangeEvent.Operation.POLICY_VERSION_ADDED, + PapChangeEvent.Operation.POLICY_VERSION_DELETED, + PapChangeEvent.Operation.POLICY_VERSION_ADDED, + PapChangeEvent.Operation.POLICY_DELETED, + PapChangeEvent.Operation.ATTRIBUTE_PROVIDERS_CHANGED, + PapChangeEvent.Operation.PDP_CONFIGURATION_CHANGED, + PapChangeEvent.Operation.DOMAIN_DELETED)); + assertTrue(Listener.EVENTS.stream().allMatch(event -> id.equals(event.domainId()))); + } +} + diff --git a/webapp/src/test/java/org/ow2/authzforce/webapp/test/RestServiceTest.java b/webapp/src/test/java/org/ow2/authzforce/webapp/test/RestServiceTest.java index 07ee306..c574b56 100644 --- a/webapp/src/test/java/org/ow2/authzforce/webapp/test/RestServiceTest.java +++ b/webapp/src/test/java/org/ow2/authzforce/webapp/test/RestServiceTest.java @@ -262,7 +262,7 @@ private static ContextEnvironment newJndiEnvEntry(final String name, final Class * @throws LifecycleException Tomcat startup error */ private static Tomcat startServer(final int port, final boolean enableFastInfoset, final String xacmlJsonSchemaRelativePath, final boolean enableDoSMitigation, final int domainSyncIntervalSec, final boolean enablePdpOnly, - final boolean addSampleDomain) throws IllegalArgumentException, IOException, LifecycleException + final boolean addSampleDomain, final String papChangeListenerClass) throws IllegalArgumentException, IOException, LifecycleException { /* * Make sure the domains directory exists and is empty @@ -330,6 +330,11 @@ private static Tomcat startServer(final int port, final boolean enableFastInfose // Initialize server JNDI properties for embedded Tomcat final NamingResources webappNamingResources = webappCtx.getNamingResources(); + if (!papChangeListenerClass.isEmpty()) + { + webappNamingResources.addEnvironment(newJndiEnvEntry( + "org.ow2.authzforce.rest.service.PapChangeListener.class", String.class, papChangeListenerClass)); + } /* * Override spring active profile context parameter with system property (no way to override context parameter with Tomcat embedded API, otherwise error @@ -377,6 +382,11 @@ protected enum ClientType XML, FAST_INFOSET, JSON } + protected String getPapChangeListenerClassName() + { + return ""; + } + protected void startServerAndInitCLient(final String remoteAppBaseUrl, final ClientType clientType, final String xacmlJsonSchemaRelativePath, final boolean enableDoSMitigation, final int domainSyncIntervalSec, final boolean enablePdpOnly) throws Exception { @@ -386,7 +396,7 @@ protected void startServerAndInitCLient(final String remoteAppBaseUrl, final Cli if (!IS_EMBEDDED_SERVER_STARTED.get() && (remoteAppBaseUrl == null || remoteAppBaseUrl.isEmpty())) { // Not a remote server -> start the embedded server (local) - embeddedServer = startServer(-1, clientType == ClientType.FAST_INFOSET, xacmlJsonSchemaRelativePath, enableDoSMitigation, domainSyncIntervalSec, enablePdpOnly, false); + embeddedServer = startServer(-1, clientType == ClientType.FAST_INFOSET, xacmlJsonSchemaRelativePath, enableDoSMitigation, domainSyncIntervalSec, enablePdpOnly, false, getPapChangeListenerClassName()); IS_EMBEDDED_SERVER_STARTED.set(true); } @@ -561,7 +571,7 @@ else if (args.length != 5) } } - final Tomcat tomcat = startServer(port, enableFastInfoset, "", enableDoSMitigation, domainSyncIntervalSec, enablePdpOnly, true); + final Tomcat tomcat = startServer(port, enableFastInfoset, "", enableDoSMitigation, domainSyncIntervalSec, enablePdpOnly, true, ""); System.out.println("Server up and listening!"); tomcat.getServer().await(); } diff --git a/webapp/src/test/suites/pap_notifications.xml b/webapp/src/test/suites/pap_notifications.xml new file mode 100644 index 0000000..7678acb --- /dev/null +++ b/webapp/src/test/suites/pap_notifications.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + +