001/*
002 * Licensed to DuraSpace under one or more contributor license agreements.
003 * See the NOTICE file distributed with this work for additional information
004 * regarding copyright ownership.
005 *
006 * DuraSpace licenses this file to you under the Apache License,
007 * Version 2.0 (the "License"); you may not use this file except in
008 * compliance with the License.  You may obtain a copy of the License at
009 *
010 *     http://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018package org.fcrepo.audit;
019
020import static org.apache.jena.datatypes.xsd.XSDDatatype.XSDdateTime;
021import static org.apache.jena.datatypes.xsd.XSDDatatype.XSDstring;
022import static org.apache.jena.rdf.model.ModelFactory.createDefaultModel;
023import static org.apache.jena.rdf.model.ResourceFactory.createProperty;
024import static org.apache.jena.rdf.model.ResourceFactory.createResource;
025import static org.apache.jena.rdf.model.ResourceFactory.createTypedLiteral;
026import static java.util.EnumSet.noneOf;
027import static org.fcrepo.kernel.api.observer.OptionalValues.BASE_URL;
028import static org.fcrepo.kernel.api.observer.OptionalValues.USER_AGENT;
029import static org.fcrepo.kernel.modeshape.utils.FedoraTypesUtils.getJcrNode;
030import static org.slf4j.LoggerFactory.getLogger;
031
032import static org.fcrepo.audit.AuditProperties.INTERNAL_EVENT;
033import static org.fcrepo.audit.AuditProperties.PREMIS_AGENT;
034import static org.fcrepo.audit.AuditProperties.PREMIS_EVENT;
035import static org.fcrepo.audit.AuditProperties.PREMIS_TIME;
036import static org.fcrepo.audit.AuditProperties.PREMIS_TYPE;
037import static org.fcrepo.audit.AuditProperties.PROV_EVENT;
038import static org.fcrepo.audit.AuditProperties.RDF_TYPE;
039
040import java.io.IOException;
041import java.net.URI;
042import java.net.URISyntaxException;
043import java.text.DateFormat;
044import java.text.SimpleDateFormat;
045import java.time.Instant;
046import java.util.Set;
047import java.util.TimeZone;
048
049import javax.annotation.PostConstruct;
050import javax.annotation.PreDestroy;
051import javax.inject.Inject;
052import javax.jcr.PropertyType;
053import javax.jcr.RepositoryException;
054
055import org.fcrepo.kernel.api.RequiredRdfContext;
056import org.fcrepo.kernel.api.exception.RepositoryRuntimeException;
057import org.fcrepo.kernel.api.identifiers.IdentifierConverter;
058import org.fcrepo.kernel.api.models.FedoraResource;
059import org.fcrepo.kernel.api.observer.FedoraEvent;
060import org.fcrepo.kernel.api.services.ContainerService;
061import org.fcrepo.kernel.modeshape.rdf.impl.PrefixingIdentifierTranslator;
062
063import org.modeshape.jcr.api.JcrTools;
064import org.modeshape.jcr.api.Repository;
065import org.modeshape.jcr.api.Session;
066import org.slf4j.Logger;
067
068import org.apache.jena.rdf.model.Model;
069import org.apache.jena.rdf.model.RDFNode;
070import org.apache.jena.rdf.model.Resource;
071import org.apache.jena.rdf.model.ResourceFactory;
072import org.apache.jena.rdf.model.Statement;
073import com.google.common.annotations.VisibleForTesting;
074import com.google.common.eventbus.EventBus;
075import com.google.common.eventbus.Subscribe;
076
077/**
078 * Auditor implementation that creates audit nodes in the repository.
079 * @author mohideen
080 * @author escowles
081 * @since 2015-04-15
082 */
083public class InternalAuditor implements Auditor {
084
085    /**
086     * Logger for this class.
087     */
088    private static final Logger LOGGER = getLogger(InternalAuditor.class);
089
090    private static final String AUDIT_CONTAINER = "fcrepo.audit.container";
091
092    private static String AUDIT_CONTAINER_LOCATION;
093
094    @Inject
095    private EventBus eventBus;
096
097    @Inject
098    private Repository repository;
099
100    @Inject
101    private ContainerService containerService;
102
103    private static final UuidPathMinter pathMinter = new UuidPathMinter();
104
105    private Session session;
106    private static JcrTools jcrTools = new JcrTools(true);
107
108    /**
109     * Register with the EventBus to receive events.
110     */
111    @PostConstruct
112    public void register() {
113        try {
114            AUDIT_CONTAINER_LOCATION = System.getProperty(AUDIT_CONTAINER);
115            if (AUDIT_CONTAINER_LOCATION != null) {
116                LOGGER.info("Initializing: {}, {}", this.getClass().getCanonicalName(), AUDIT_CONTAINER_LOCATION);
117                eventBus.register(this);
118                if (!AUDIT_CONTAINER_LOCATION.startsWith("/")) {
119                    AUDIT_CONTAINER_LOCATION = "/" + AUDIT_CONTAINER_LOCATION;
120                }
121                if (AUDIT_CONTAINER_LOCATION.endsWith("/")) {
122                    AUDIT_CONTAINER_LOCATION = AUDIT_CONTAINER_LOCATION.substring(0,
123                            AUDIT_CONTAINER_LOCATION.length() - 2);
124                }
125                session = repository.login();
126                containerService.findOrCreate(session, AUDIT_CONTAINER_LOCATION);
127
128                LOGGER.debug("Registering audit CND");
129                jcrTools.registerNodeTypes(session, "audit.cnd");
130
131                session.save();
132            } else {
133                LOGGER.warn("Cannot Initialize: {}", this.getClass().getCanonicalName());
134                LOGGER.warn("System property not found: " + AUDIT_CONTAINER);
135            }
136        } catch (RepositoryException e) {
137            throw new RepositoryRuntimeException(e);
138        }
139    }
140
141    /**
142     * Fedora internal events are received and processed by this method.
143     *
144     * @param event
145     *        The {@link FedoraEvent} to record.
146     */
147    @Subscribe
148    public void recordEvent(final FedoraEvent event) {
149        LOGGER.debug("Event detected: {} {}", event.getUserID(), event.getPath());
150        if (!event.getPath().startsWith(AUDIT_CONTAINER_LOCATION) && !event.getPath().isEmpty()) {
151            try {
152                createAuditNode(event);
153            } catch (IOException e) {
154                throw new RepositoryRuntimeException(e);
155            }
156        }
157    }
158
159    /**
160     * Close external connections
161     */
162    @PreDestroy
163    public void releaseConnections() {
164        LOGGER.debug("Tearing down: {}", this.getClass().getCanonicalName());
165        eventBus.unregister(this);
166    }
167
168    // JCR property name, not URI
169    private static final String PREMIS_OBJ = "premis:hasEventRelatedObject";
170
171    /**
172     * Creates a node for the audit event under the configured container.
173     *
174     * @param event to be persisted in the repository
175     * @throws java.io.IOException on json mapping error
176     */
177    public void createAuditNode(final FedoraEvent event) throws IOException {
178        try {
179            final String userAgent = event.getInfo().get(USER_AGENT);
180            final String baseURL = event.getInfo().get(BASE_URL);
181            final String path = event.getPath();
182            final String uri = baseURL + path;
183            final Instant timestamp =  event.getDate();
184            final DateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
185            df.setTimeZone(TimeZone.getTimeZone("UTC"));
186            final String eventDate = df.format(timestamp.toEpochMilli());
187            final String userID = event.getUserID();
188            final Set<String> eventTypes = AuditUtils.getEventURIs(event.getTypes());
189            final Set<String> resourceTypes = event.getResourceTypes();
190            final String auditEventType = AuditUtils.getAuditEventType(eventTypes, resourceTypes);
191
192            final String eventPath = getEventPath(event.getEventID());
193            final FedoraResource auditResource = containerService.findOrCreate(session,
194                    AUDIT_CONTAINER_LOCATION + "/" + eventPath);
195
196            LOGGER.debug("Audit node {} created for event.", event.getEventID());
197
198            final Model m = createDefaultModel();
199            final String auditResourceURI = baseURL + AUDIT_CONTAINER_LOCATION + "/" + eventPath;
200            final Resource s = createResource(auditResourceURI);
201            m.add(createStatement(s, RDF_TYPE, createResource(INTERNAL_EVENT)));
202            m.add(createStatement(s, RDF_TYPE, createResource(PREMIS_EVENT)));
203            m.add(createStatement(s, RDF_TYPE, createResource(PROV_EVENT)));
204            m.add(createStatement(s, PREMIS_TIME, createTypedLiteral(eventDate, XSDdateTime)));
205            m.add(createStatement(s, PREMIS_AGENT, createTypedLiteral(userID, XSDstring)));
206            m.add(createStatement(s, PREMIS_AGENT, createTypedLiteral(userAgent, XSDstring)));
207            if (auditEventType != null) {
208                m.add(createStatement(s, PREMIS_TYPE, createResource(auditEventType)));
209            }
210
211            final IdentifierConverter<Resource, FedoraResource> translator =
212                new PrefixingIdentifierTranslator(session, baseURL + "/");
213            auditResource.replaceProperties(translator, m,
214                    auditResource.getTriples(translator, noneOf(RequiredRdfContext.class)));
215
216            // set link to impacted object using a URI property to preserve the link if it's deleted
217            try {
218                getJcrNode(auditResource).setProperty(PREMIS_OBJ, new URI(uri).toString(), PropertyType.URI);
219            } catch (URISyntaxException e) {
220                LOGGER.warn("Error creating URI for repository resource {}", uri);
221            }
222
223            session.save();
224        } catch (RepositoryException e) {
225            throw new RepositoryRuntimeException(e);
226        }
227    }
228
229    @VisibleForTesting
230    protected String getEventPath(final String eventID) {
231        if (!eventID.startsWith("urn:uuid:")) {
232            throw new IllegalArgumentException("Event ID must be a 'urn:uuid:'" + eventID);
233        }
234        return pathMinter.get(eventID.substring("urn:uuid:".length()));
235    }
236
237    @VisibleForTesting
238    protected Statement createStatement(final Resource subject, final String property, final RDFNode object) {
239        return ResourceFactory.createStatement(subject, createProperty(property), object);
240    }
241
242}