View Javadoc
1   /*
2    * Licensed to The Apereo Foundation under one or more contributor license
3    * agreements. See the NOTICE file distributed with this work for additional
4    * information regarding copyright ownership.
5    *
6    *
7    * The Apereo Foundation licenses this file to you under the Educational
8    * Community License, Version 2.0 (the "License"); you may not use this file
9    * except in compliance with the License. You may obtain a copy of the License
10   * at:
11   *
12   *   http://opensource.org/licenses/ecl2.txt
13   *
14   * Unless required by applicable law or agreed to in writing, software
15   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
16   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
17   * License for the specific language governing permissions and limitations under
18   * the License.
19   *
20   */
21  
22  package org.opencastproject.search.impl;
23  
24  import static org.opencastproject.security.api.SecurityConstants.GLOBAL_ADMIN_ROLE;
25  import static org.opencastproject.util.data.functions.Functions.chuck;
26  
27  import org.opencastproject.job.api.AbstractJobProducer;
28  import org.opencastproject.job.api.Job;
29  import org.opencastproject.mediapackage.MediaPackage;
30  import org.opencastproject.mediapackage.MediaPackageParser;
31  import org.opencastproject.search.api.SearchException;
32  import org.opencastproject.search.api.SearchResultList;
33  import org.opencastproject.search.api.SearchService;
34  import org.opencastproject.search.impl.persistence.SearchServiceDatabase;
35  import org.opencastproject.search.impl.persistence.SearchServiceDatabaseException;
36  import org.opencastproject.security.api.Organization;
37  import org.opencastproject.security.api.OrganizationDirectoryService;
38  import org.opencastproject.security.api.SecurityService;
39  import org.opencastproject.security.api.StaticFileAuthorization;
40  import org.opencastproject.security.api.UnauthorizedException;
41  import org.opencastproject.security.api.User;
42  import org.opencastproject.security.api.UserDirectoryService;
43  import org.opencastproject.security.util.SecurityUtil;
44  import org.opencastproject.serviceregistry.api.ServiceRegistry;
45  import org.opencastproject.serviceregistry.api.ServiceRegistryException;
46  import org.opencastproject.util.LoadUtil;
47  import org.opencastproject.util.NotFoundException;
48  import org.opencastproject.util.data.Tuple;
49  
50  import com.google.common.cache.CacheBuilder;
51  import com.google.common.cache.CacheLoader;
52  import com.google.common.cache.LoadingCache;
53  
54  import org.apache.commons.lang3.tuple.Pair;
55  import org.elasticsearch.action.search.SearchResponse;
56  import org.elasticsearch.search.builder.SearchSourceBuilder;
57  import org.osgi.service.component.ComponentContext;
58  import org.osgi.service.component.annotations.Activate;
59  import org.osgi.service.component.annotations.Component;
60  import org.osgi.service.component.annotations.Modified;
61  import org.osgi.service.component.annotations.Reference;
62  import org.slf4j.Logger;
63  import org.slf4j.LoggerFactory;
64  
65  import java.util.Collection;
66  import java.util.Collections;
67  import java.util.List;
68  import java.util.Map;
69  import java.util.concurrent.TimeUnit;
70  import java.util.regex.Matcher;
71  import java.util.regex.Pattern;
72  
73  /**
74   * An Opensearch-based {@link SearchService} implementation.
75   */
76  @Component(
77      immediate = true,
78      service = { SearchService.class, SearchServiceImpl.class, StaticFileAuthorization.class },
79      property = {
80          "service.description=Search Service",
81          "service.pid=org.opencastproject.search.impl.SearchServiceImpl"
82      }
83  )
84  public final class SearchServiceImpl extends AbstractJobProducer implements SearchService, StaticFileAuthorization {
85  
86    /** Log facility */
87    private static final Logger logger = LoggerFactory.getLogger(SearchServiceImpl.class);
88  
89    /** The job type */
90    public static final String JOB_TYPE = "org.opencastproject.search";
91  
92    /** The load introduced on the system by creating an add job */
93    public static final float DEFAULT_ADD_JOB_LOAD = 0.1f;
94  
95    /** The load introduced on the system by creating a delete job */
96    public static final float DEFAULT_DELETE_JOB_LOAD = 0.1f;
97  
98    /** The key to look for in the service configuration file to override the {@link #DEFAULT_ADD_JOB_LOAD} */
99    public static final String ADD_JOB_LOAD_KEY = "job.load.add";
100 
101   /** The key to look for in the service configuration file to override the {@link #DEFAULT_DELETE_JOB_LOAD} */
102   public static final String DELETE_JOB_LOAD_KEY = "job.load.delete";
103 
104   /** The load introduced on the system by creating an add job */
105   private float addJobLoad = DEFAULT_ADD_JOB_LOAD;
106 
107   /** The load introduced on the system by creating a delete job */
108   private float deleteJobLoad = DEFAULT_DELETE_JOB_LOAD;
109 
110   /** List of available operations on jobs */
111   private enum Operation {
112     Add, Delete, DeleteSeries
113   }
114 
115   private SearchServiceIndex index;
116 
117   /** The security service */
118   private SecurityService securityService;
119 
120   /** The service registry */
121   private ServiceRegistry serviceRegistry;
122 
123   /** Persistent storage */
124   private SearchServiceDatabase persistence;
125 
126   /** The user directory service */
127   protected UserDirectoryService userDirectoryService = null;
128 
129   /** The organization directory service */
130   protected OrganizationDirectoryService organizationDirectory = null;
131 
132   private final LoadingCache<Tuple<User, String>, Boolean> cache;
133 
134   private static final Pattern staticFilePattern =
135       Pattern.compile("^/([^/]+)/(?:engage-player|engage-live)/([^/]+)/.*$");
136 
137   /**
138    * Creates a new instance of the search service.
139    */
140   public SearchServiceImpl() {
141     super(JOB_TYPE);
142 
143     cache = CacheBuilder.newBuilder()
144         .maximumSize(2048)
145         .expireAfterWrite(1, TimeUnit.MINUTES)
146         .build(new CacheLoader<>() {
147           @Override
148           public Boolean load(Tuple<User, String> key) {
149             return loadUrlAccess(key.getB());
150           }
151         });
152   }
153 
154   /**
155    * Service activator, called via declarative services configuration.
156    *
157    * @param cc
158    *          the component context
159    */
160   @Override
161   @Activate
162   public void activate(final ComponentContext cc) throws IllegalStateException {
163     super.activate(cc);
164   }
165 
166   /**
167    * {@inheritDoc}
168    *
169    * @see org.opencastproject.search.api.SearchService#add(org.opencastproject.mediapackage.MediaPackage)
170    */
171   public Job add(MediaPackage mediaPackage) throws SearchException, IllegalArgumentException {
172     try {
173       return serviceRegistry.createJob(JOB_TYPE, Operation.Add.toString(),
174           Collections.singletonList(MediaPackageParser.getAsXml(mediaPackage)), addJobLoad);
175     } catch (ServiceRegistryException e) {
176       throw new SearchException(e);
177     }
178   }
179 
180   @Override
181   public void addSynchronously(MediaPackage mediaPackage)
182           throws SearchException, IllegalArgumentException, UnauthorizedException {
183     try {
184       index.addSynchronously(mediaPackage);
185     } catch (SearchServiceDatabaseException e) {
186       throw new SearchException(e);
187     }
188   }
189 
190   @Override
191   public Collection<Pair<Organization, MediaPackage>> getSeries(String seriesId) {
192     try {
193       return persistence.getSeries(seriesId);
194     } catch (SearchServiceDatabaseException e) {
195       throw new SearchException(e);
196     }
197   }
198 
199   /**
200    * {@inheritDoc}
201    *
202    * @see org.opencastproject.search.api.SearchService#delete(java.lang.String)
203    */
204   public Job delete(String mediaPackageId) throws SearchException {
205     try {
206       return serviceRegistry.createJob(
207         JOB_TYPE, Operation.Delete.toString(), Collections.singletonList(mediaPackageId), deleteJobLoad);
208     } catch (ServiceRegistryException e) {
209       throw new SearchException(e);
210     }
211   }
212 
213   public boolean deleteSynchronously(String mediaPackageId) throws SearchException {
214     return index.deleteSynchronously(mediaPackageId);
215   }
216 
217   /**
218    * {@inheritDoc}
219    *
220    * @see org.opencastproject.search.api.SearchService#deleteSeries(java.lang.String)
221    */
222   public Job deleteSeries(String seriesId) throws SearchException {
223     try {
224       return serviceRegistry.createJob(
225           JOB_TYPE, Operation.DeleteSeries.toString(), Collections.singletonList(seriesId), deleteJobLoad);
226     } catch (ServiceRegistryException e) {
227       throw new SearchException(e);
228     }
229   }
230 
231   @Override
232   public MediaPackage get(String mediaPackageId) throws NotFoundException, UnauthorizedException {
233     try {
234       return persistence.getMediaPackage(mediaPackageId);
235     } catch (SearchServiceDatabaseException e) {
236       throw new SearchException(e);
237     }
238   }
239 
240   public SearchResultList search(SearchSourceBuilder searchSource) throws SearchException {
241     SearchResponse idxResp = this.index.search(searchSource);
242     return new SearchResultList(idxResp.getHits());
243   }
244 
245 
246   /**
247    * @see org.opencastproject.job.api.AbstractJobProducer#process(org.opencastproject.job.api.Job)
248    */
249   @Override
250   protected String process(Job job) throws Exception {
251     Operation op = null;
252     String operation = job.getOperation();
253     List<String> arguments = job.getArguments();
254     try {
255       op = Operation.valueOf(operation);
256       Organization org = organizationDirectory.getOrganization(job.getOrganization());
257       User user = userDirectoryService.loadUser(job.getCreator());
258       boolean[] deleted = new boolean[1];
259       switch (op) {
260         case Add:
261           MediaPackage mediaPackage = MediaPackageParser.getFromXml(arguments.get(0));
262           SecurityUtil.runAs(securityService, org, user, () -> {
263             try {
264               index.addSynchronously(mediaPackage);
265             } catch (UnauthorizedException | SearchServiceDatabaseException e) {
266               chuck(e);
267             }
268           });
269           return null;
270         case Delete:
271           String mediapackageId = arguments.get(0);
272           SecurityUtil.runAs(securityService, org, user, () -> {
273             deleted[0] = index.deleteSynchronously(mediapackageId);
274           });
275           return Boolean.toString(deleted[0]);
276         case DeleteSeries:
277           String seriesId = arguments.get(0);
278           SecurityUtil.runAs(securityService, org, user, () -> {
279             deleted[0] = index.deleteSeriesSynchronously(seriesId);
280           });
281           return Boolean.toString(deleted[0]);
282         default:
283           throw new IllegalStateException("Don't know how to handle operation '" + operation + "'");
284       }
285     } catch (IllegalArgumentException e) {
286       throw new ServiceRegistryException("This service can't handle operations of type '" + op + "'", e);
287     } catch (IndexOutOfBoundsException e) {
288       throw new ServiceRegistryException("This argument list for operation '" + op + "' does not meet expectations", e);
289     } catch (Exception e) {
290       throw new ServiceRegistryException("Error handling operation '" + op + "'", e);
291     }
292   }
293 
294   @Reference
295   public void setSearchIndex(SearchServiceIndex ssi) {
296     this.index = ssi;
297   }
298 
299   @Reference
300   public void setPersistence(SearchServiceDatabase persistence) {
301     this.persistence = persistence;
302   }
303 
304 
305   @Reference
306   public void setServiceRegistry(ServiceRegistry serviceRegistry) {
307     this.serviceRegistry = serviceRegistry;
308   }
309 
310   /**
311    * Callback for setting the security service.
312    *
313    * @param securityService
314    *          the securityService to set
315    */
316   @Reference
317   public void setSecurityService(SecurityService securityService) {
318     this.securityService = securityService;
319   }
320 
321   /**
322    * Callback for setting the user directory service.
323    *
324    * @param userDirectoryService
325    *          the userDirectoryService to set
326    */
327   @Reference
328   public void setUserDirectoryService(UserDirectoryService userDirectoryService) {
329     this.userDirectoryService = userDirectoryService;
330   }
331 
332   /**
333    * Sets a reference to the organization directory service.
334    *
335    * @param organizationDirectory
336    *          the organization directory
337    */
338   @Reference
339   public void setOrganizationDirectoryService(OrganizationDirectoryService organizationDirectory) {
340     this.organizationDirectory = organizationDirectory;
341   }
342 
343   /**
344    * @see org.opencastproject.job.api.AbstractJobProducer#getOrganizationDirectoryService()
345    */
346   @Override
347   protected OrganizationDirectoryService getOrganizationDirectoryService() {
348     return organizationDirectory;
349   }
350 
351   /**
352    * @see org.opencastproject.job.api.AbstractJobProducer#getSecurityService()
353    */
354   @Override
355   protected SecurityService getSecurityService() {
356     return securityService;
357   }
358 
359   /**
360    * @see org.opencastproject.job.api.AbstractJobProducer#getServiceRegistry()
361    */
362   @Override
363   protected ServiceRegistry getServiceRegistry() {
364     return serviceRegistry;
365   }
366 
367   /**
368    * @see org.opencastproject.job.api.AbstractJobProducer#getUserDirectoryService()
369    */
370   @Override
371   protected UserDirectoryService getUserDirectoryService() {
372     return userDirectoryService;
373   }
374 
375   @Modified
376   public void modified(Map<String, Object> properties) {
377     if (properties == null) {
378       logger.info("No configuration available, using defaults");
379       properties = Map.of();
380     }
381 
382     logger.info("Configuring SearchServiceImpl");
383     addJobLoad = LoadUtil.getConfiguredLoadValue(properties, ADD_JOB_LOAD_KEY, DEFAULT_ADD_JOB_LOAD, serviceRegistry);
384     deleteJobLoad = LoadUtil.getConfiguredLoadValue(
385         properties, DELETE_JOB_LOAD_KEY, DEFAULT_DELETE_JOB_LOAD, serviceRegistry);
386   }
387 
388   @Override
389   public List<Pattern> getProtectedUrlPattern() {
390     return Collections.singletonList(staticFilePattern);
391   }
392 
393   private boolean loadUrlAccess(final String mediaPackageId) {
394     logger.debug("Check if user `{}` has access to media package `{}`", securityService.getUser(), mediaPackageId);
395     try {
396       persistence.getMediaPackage(mediaPackageId);
397     } catch (NotFoundException | SearchServiceDatabaseException | UnauthorizedException e) {
398       return false;
399     }
400     return true;
401   }
402 
403   @Override
404   public boolean verifyUrlAccess(final String path) {
405     // Always allow access for admin
406     final User user = securityService.getUser();
407     if (user.hasRole(GLOBAL_ADMIN_ROLE)) {
408       logger.debug("Allow access for admin `{}`", user);
409       return true;
410     }
411 
412     // Check pattern
413     final Matcher m = staticFilePattern.matcher(path);
414     if (!m.matches()) {
415       logger.debug("Path does not match pattern. Preventing access.");
416       return false;
417     }
418 
419     // Check organization
420     final String organizationId = m.group(1);
421     if (!securityService.getOrganization().getId().equals(organizationId)) {
422       logger.debug("The user's organization does not match. Preventing access.");
423       return false;
424     }
425 
426     // Check search index/cache
427     final String mediaPackageId = m.group(2);
428     final boolean access = cache.getUnchecked(Tuple.tuple(user, mediaPackageId));
429     logger.debug("Check if user `{}` has access to media package `{}` using cache: {}", user, mediaPackageId, access);
430     return access;
431   }
432 }