1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 package org.opencastproject.search.impl;
23
24 import static org.opencastproject.security.api.Permissions.Action.WRITE;
25 import static org.opencastproject.security.util.SecurityUtil.getEpisodeRoleId;
26
27 import org.opencastproject.elasticsearch.index.ElasticsearchIndex;
28 import org.opencastproject.elasticsearch.index.rebuild.AbstractIndexProducer;
29 import org.opencastproject.elasticsearch.index.rebuild.IndexProducer;
30 import org.opencastproject.elasticsearch.index.rebuild.IndexRebuildException;
31 import org.opencastproject.elasticsearch.index.rebuild.IndexRebuildService;
32 import org.opencastproject.list.api.ListProviderException;
33 import org.opencastproject.list.api.ListProvidersService;
34 import org.opencastproject.list.api.ResourceListQuery;
35 import org.opencastproject.list.impl.ResourceListQueryImpl;
36 import org.opencastproject.mediapackage.MediaPackage;
37 import org.opencastproject.metadata.dublincore.DublinCore;
38 import org.opencastproject.metadata.dublincore.DublinCoreCatalog;
39 import org.opencastproject.metadata.dublincore.DublinCoreUtil;
40 import org.opencastproject.metadata.dublincore.DublinCoreValue;
41 import org.opencastproject.metadata.dublincore.DublinCores;
42 import org.opencastproject.search.api.SearchException;
43 import org.opencastproject.search.api.SearchResult;
44 import org.opencastproject.search.api.SearchService;
45 import org.opencastproject.search.impl.persistence.SearchServiceDatabase;
46 import org.opencastproject.search.impl.persistence.SearchServiceDatabaseException;
47 import org.opencastproject.security.api.AccessControlEntry;
48 import org.opencastproject.security.api.AccessControlList;
49 import org.opencastproject.security.api.AccessControlUtil;
50 import org.opencastproject.security.api.AuthorizationService;
51 import org.opencastproject.security.api.Organization;
52 import org.opencastproject.security.api.OrganizationDirectoryService;
53 import org.opencastproject.security.api.SecurityService;
54 import org.opencastproject.security.api.UnauthorizedException;
55 import org.opencastproject.security.api.User;
56 import org.opencastproject.security.util.SecurityUtil;
57 import org.opencastproject.series.api.SeriesException;
58 import org.opencastproject.series.api.SeriesService;
59 import org.opencastproject.util.NotFoundException;
60 import org.opencastproject.util.data.Tuple;
61 import org.opencastproject.workspace.api.Workspace;
62
63 import com.google.gson.Gson;
64 import com.google.gson.JsonElement;
65
66 import org.apache.commons.io.IOUtils;
67 import org.apache.commons.lang3.BooleanUtils;
68 import org.elasticsearch.ElasticsearchStatusException;
69 import org.elasticsearch.action.DocWriteResponse;
70 import org.elasticsearch.action.index.IndexRequest;
71 import org.elasticsearch.action.search.SearchRequest;
72 import org.elasticsearch.action.search.SearchResponse;
73 import org.elasticsearch.action.update.UpdateRequest;
74 import org.elasticsearch.action.update.UpdateResponse;
75 import org.elasticsearch.client.RequestOptions;
76 import org.elasticsearch.client.indices.CreateIndexRequest;
77 import org.elasticsearch.common.xcontent.XContentType;
78 import org.elasticsearch.rest.RestStatus;
79 import org.elasticsearch.search.builder.SearchSourceBuilder;
80 import org.osgi.service.component.ComponentContext;
81 import org.osgi.service.component.annotations.Activate;
82 import org.osgi.service.component.annotations.Component;
83 import org.osgi.service.component.annotations.Reference;
84 import org.slf4j.Logger;
85 import org.slf4j.LoggerFactory;
86
87 import java.io.IOException;
88 import java.io.InputStream;
89 import java.nio.charset.StandardCharsets;
90 import java.time.Instant;
91 import java.time.format.DateTimeFormatter;
92 import java.util.ArrayList;
93 import java.util.Collections;
94 import java.util.Date;
95 import java.util.HashMap;
96 import java.util.HashSet;
97 import java.util.List;
98 import java.util.Map;
99 import java.util.Objects;
100 import java.util.Set;
101 import java.util.concurrent.atomic.AtomicInteger;
102 import java.util.stream.Collectors;
103
104
105
106
107 @Component(
108 immediate = true,
109 service = { SearchServiceIndex.class, IndexProducer.class },
110 property = {
111 "service.description=Search Service Index",
112 "service.pid=org.opencastproject.search.impl.SearchServiceIndex"
113 }
114 )
115 public final class SearchServiceIndex extends AbstractIndexProducer implements IndexProducer {
116
117 @Override
118 public IndexRebuildService.Service getService() {
119 return IndexRebuildService.Service.Search;
120 }
121
122
123 private static final Logger logger = LoggerFactory.getLogger(SearchServiceIndex.class);
124
125 public static final String INDEX_NAME = "opencast_search";
126
127 private final Gson gson = new Gson();
128
129 private ElasticsearchIndex esIndex;
130
131 private SeriesService seriesService;
132
133
134 private Workspace workspace;
135
136
137 private SecurityService securityService;
138
139
140 private AuthorizationService authorizationService;
141
142
143 private SearchServiceDatabase persistence;
144
145
146 private OrganizationDirectoryService organizationDirectory = null;
147
148 private ListProvidersService listProvidersService;
149
150 private static final String CONFIG_EPISODE_ID_ROLE = "org.opencastproject.episode.id.role.access";
151
152 private boolean episodeIdRole = false;
153
154 private String systemUserName = null;
155
156
157
158
159
160 public SearchServiceIndex() {
161 }
162
163
164
165
166
167
168
169 @Activate
170 public void activate(final ComponentContext cc) throws IllegalStateException {
171 episodeIdRole = BooleanUtils.toBoolean(Objects.toString(
172 cc.getBundleContext().getProperty(CONFIG_EPISODE_ID_ROLE), "false"));
173 logger.debug("Usage of episode ID roles is set to {}", episodeIdRole);
174
175 createIndex();
176 systemUserName = SecurityUtil.getSystemUserName(cc);
177 }
178
179 private void createIndex() {
180 var mapping = "";
181 try (var in = this.getClass().getResourceAsStream("/search-mapping.json")) {
182 mapping = IOUtils.toString(in, StandardCharsets.UTF_8);
183 } catch (IOException e) {
184 throw new SearchException("Could not read mapping.", e);
185 }
186 try {
187 logger.debug("Trying to create index for '{}'", INDEX_NAME);
188 InputStream is = getClass().getResourceAsStream("/elasticsearch/indexSettings.json");
189 String indexSettings = IOUtils.toString(is, StandardCharsets.UTF_8);
190 final CreateIndexRequest request = new CreateIndexRequest(INDEX_NAME)
191 .settings(indexSettings, XContentType.JSON)
192 .mapping(mapping, XContentType.JSON);
193 var response = esIndex.getClient().indices().create(request, RequestOptions.DEFAULT);
194 if (!response.isAcknowledged()) {
195 throw new SearchException("Unable to create index for '" + INDEX_NAME + "'");
196 }
197 } catch (ElasticsearchStatusException e) {
198 if (e.getDetailedMessage().contains("already_exists_exception")) {
199 logger.info("Detected existing index '{}'", INDEX_NAME);
200 } else {
201 throw e;
202 }
203 } catch (IOException e) {
204 throw new SearchException(e);
205 }
206 }
207
208 @Reference
209 public void setEsIndex(ElasticsearchIndex esIndex) {
210 this.esIndex = esIndex;
211 }
212
213
214 public SearchResponse search(SearchSourceBuilder searchSource) throws SearchException {
215 SearchRequest searchRequest = new SearchRequest(INDEX_NAME);
216 logger.debug("Sending for query: {}", searchSource.query());
217 searchRequest.source(searchSource);
218 try {
219 return esIndex.getClient().search(searchRequest, RequestOptions.DEFAULT);
220 } catch (IOException e) {
221 throw new SearchException(e);
222 }
223 }
224
225
226
227
228
229
230
231
232
233
234
235
236
237 public void addSynchronously(MediaPackage mediaPackage)
238 throws SearchException, IllegalArgumentException, UnauthorizedException, SearchServiceDatabaseException {
239 if (mediaPackage == null) {
240 throw new IllegalArgumentException("Unable to add a null mediapackage");
241 }
242 var mediaPackageId = mediaPackage.getIdentifier().toString();
243
244 checkSearchEntityWritePermission(mediaPackageId);
245
246 logger.debug("Attempting to add media package {} to search index", mediaPackageId);
247 final var acls = new AccessControlList[1];
248 final var org = securityService.getOrganization();
249 final var systemUser = SecurityUtil.createSystemUser(systemUserName, org);
250
251 SecurityUtil.runAs(securityService, org, systemUser, () -> {
252 acls[0] = authorizationService.getActiveAcl(mediaPackage).getA();
253 });
254 var acl = acls[0] == null ? new AccessControlList() : acls[0];
255 var now = new Date();
256
257 try {
258 persistence.storeMediaPackage(mediaPackage, acl, now);
259 } catch (SearchServiceDatabaseException e) {
260 throw new SearchException(String.format("Could not store media package to search database %s", mediaPackageId),
261 e);
262 }
263
264 indexMediaPackage(mediaPackage, acl);
265 }
266
267 public void indexMediaPackage(String mediaPackageId)
268 throws SearchException, SearchServiceDatabaseException, UnauthorizedException, NotFoundException {
269 if (!securityService.getUser().hasRole("ROLE_ADMIN")) {
270 throw new UnauthorizedException("Only global administrators may trigger manual event updates.");
271 }
272 try {
273 MediaPackage mp = persistence.getMediaPackage(mediaPackageId);
274 AccessControlList acl = persistence.getAccessControlList(mediaPackageId);
275 Date modificationDate = persistence.getModificationDate(mediaPackageId);
276 Date deletionDate = persistence.getDeletionDate(mediaPackageId);
277 indexMediaPackage(mp, acl, modificationDate, deletionDate);
278 } catch (RuntimeException e) {
279 logSkippingElement(logger, "event", mediaPackageId, e);
280 }
281 }
282
283 private void indexMediaPackage(MediaPackage mediaPackage, AccessControlList acl)
284 throws SearchException, SearchServiceDatabaseException {
285 indexMediaPackage(mediaPackage, acl, null, null);
286 }
287
288 private void indexMediaPackage(MediaPackage mediaPackage, AccessControlList acl, Date modDate, Date delDate)
289 throws SearchException, SearchServiceDatabaseException {
290 String mediaPackageId = mediaPackage.getIdentifier().toString();
291 String orgId = securityService.getOrganization().getId();
292
293 DublinCoreCatalog dc = null == delDate
294 ? DublinCoreUtil.loadEpisodeDublinCore(workspace, mediaPackage).orElse(DublinCores.mkSimple())
295 : DublinCores.mkSimple();
296
297 List<DublinCoreCatalog> seriesList = Collections.emptyList();
298 if (dc.hasValue(DublinCore.PROPERTY_IS_PART_OF)) {
299
300 seriesList = dc.get(DublinCore.PROPERTY_IS_PART_OF).stream().map(DublinCoreValue::getValue).map(s -> {
301 try {
302 return seriesService.getSeries(s);
303 } catch (NotFoundException e) {
304 logger.warn("Series {} not found during index of event {}, omitting the link from the indexed data", s,
305 mediaPackageId);
306 } catch (UnauthorizedException e) {
307 logger.warn("Not authorized for series {} during index of event {}, omitting the link from the indexed data",
308 s, mediaPackageId);
309 } catch (SeriesException e) {
310 throw new SearchException(e);
311 }
312 return null;
313 }).filter(Objects::nonNull).collect(Collectors.toList());
314 }
315
316
317 acl = addCustomAclRoles(mediaPackageId, acl);
318
319 SearchResult item = new SearchResult(SearchService.IndexEntryType.Episode, dc, acl, orgId, mediaPackage,
320 null != modDate ? modDate.toInstant() : Instant.now(),
321 null != delDate ? delDate.toInstant() : null);
322 Map<String, Object> metadata = item.dehydrateForIndex();
323 try {
324 var request = new IndexRequest(INDEX_NAME);
325 request.id(mediaPackageId);
326 request.source(metadata);
327 esIndex.getClient().index(request, RequestOptions.DEFAULT);
328 logger.debug("Indexed episode {}", mediaPackageId);
329 } catch (IOException e) {
330 throw new SearchException(e);
331 }
332
333
334 for (DublinCoreCatalog seriesDc : seriesList) {
335 String seriesId = seriesDc.getFirst(DublinCore.PROPERTY_IDENTIFIER);
336 AccessControlList seriesAcl = persistence.getAccessControlLists(seriesId, mediaPackageId).stream()
337 .map(aclPair -> addCustomAclRoles(aclPair.getKey(), aclPair.getValue()))
338 .reduce(new AccessControlList(acl.getEntries()), AccessControlList::mergeActions);
339 item = new SearchResult(SearchService.IndexEntryType.Series, seriesDc, seriesAcl, orgId,
340 null, Instant.now(), null);
341
342 Map<String, Object> seriesData = item.dehydrateForIndex();
343 try {
344 var request = new IndexRequest(INDEX_NAME);
345 request.id(seriesId);
346 request.source(seriesData);
347 esIndex.getClient().index(request, RequestOptions.DEFAULT);
348 logger.debug("Indexed series {} related to episode {}", seriesId, mediaPackageId);
349 } catch (IOException e) {
350 throw new SearchException(e);
351 }
352 }
353 }
354
355
356
357
358
359
360
361
362
363
364
365 private AccessControlList addCustomAclRoles(String mediaPackageId, AccessControlList acl) {
366
367 if (episodeIdRole) {
368 Set<AccessControlEntry> customEntries = new HashSet<>();
369 customEntries.add(new AccessControlEntry(getEpisodeRoleId(mediaPackageId, "READ"), "read", true));
370 customEntries.add(new AccessControlEntry(getEpisodeRoleId(mediaPackageId, "WRITE"), "write", true));
371
372 ResourceListQuery query = new ResourceListQueryImpl();
373 if (listProvidersService.hasProvider("ACL.ACTIONS")) {
374 Map<String, String> actions = new HashMap<>();
375 try {
376 actions = listProvidersService.getList("ACL.ACTIONS", query, true);
377 } catch (ListProviderException e) {
378 throw new SearchException("Listproviders not loaded. " + e);
379 }
380 for (String action : actions.keySet()) {
381 customEntries.add(
382 new AccessControlEntry(getEpisodeRoleId(mediaPackageId, action), action, true));
383 }
384 }
385
386 AccessControlList customRoles = new AccessControlList(new ArrayList<>(customEntries));
387 acl = customRoles.merge(acl);
388 }
389
390 return acl;
391 }
392
393 private void checkSearchEntityWritePermission(final String mediaPackageId) throws SearchException {
394 User user = securityService.getUser();
395 try {
396 AccessControlList acl = persistence.getAccessControlList(mediaPackageId);
397 if (!AccessControlUtil.isAuthorized(acl, user, securityService.getOrganization(), WRITE.toString(),
398 mediaPackageId)) {
399 throw new UnauthorizedException(user, "Write permission denied for " + mediaPackageId, acl);
400 }
401 } catch (NotFoundException e) {
402 logger.debug("Mediapackage {} does not exist or was deleted, allowing writes for user {}", mediaPackageId, user);
403 } catch (SearchServiceDatabaseException | UnauthorizedException e) {
404 throw new SearchException(e);
405 }
406 }
407
408
409
410
411
412
413
414
415
416
417 public boolean deleteSynchronously(final String mediaPackageId) throws SearchException {
418
419 checkSearchEntityWritePermission(mediaPackageId);
420
421 String deletionString = DateTimeFormatter.ISO_INSTANT.format(Instant.now());
422
423 try {
424 logger.info("Marking media package {} as deleted in search index", mediaPackageId);
425 JsonElement json = gson.toJsonTree(Map.of(
426 SearchResult.DELETED_DATE, deletionString,
427 SearchResult.MODIFIED_DATE, deletionString));
428 var updateRequst = new UpdateRequest(INDEX_NAME, mediaPackageId)
429 .doc(gson.toJson(json), XContentType.JSON);
430 esIndex.getClient().update(updateRequst, RequestOptions.DEFAULT);
431 } catch (ElasticsearchStatusException e) {
432 if (e.status().getStatus() != RestStatus.NOT_FOUND.getStatus()) {
433 throw e;
434 }
435 logger.warn("Event {} is not in the search index. Skipping deletion", mediaPackageId);
436 } catch (IOException e) {
437 throw new SearchException("Could not delete episode " + mediaPackageId + " from index", e);
438 }
439
440 try {
441 logger.info("Marking media package {} as deleted in search database", mediaPackageId);
442
443 String seriesId = null;
444 Date now = new Date();
445 try {
446 seriesId = persistence.getMediaPackage(mediaPackageId).getSeries();
447 persistence.deleteMediaPackage(mediaPackageId, now);
448 logger.info("Removed media package {} from search persistence", mediaPackageId);
449 } catch (NotFoundException e) {
450
451 logger.info("Could not find media package with id {} in persistence, but will try remove it from index anyway.",
452 mediaPackageId);
453 } catch (SearchServiceDatabaseException | UnauthorizedException e) {
454 throw new SearchException(
455 String.format("Could not delete media package with id %s from persistence storage", mediaPackageId), e);
456 }
457
458
459 if (seriesId != null) {
460 try {
461 if (!persistence.getSeries(seriesId).isEmpty()) {
462
463 final AccessControlList seriesAcl = persistence.getAccessControlLists(seriesId).stream()
464 .map(aclPair -> addCustomAclRoles(aclPair.getKey(), aclPair.getValue()))
465 .reduce(new AccessControlList(), AccessControlList::mergeActions);
466 JsonElement json = gson.toJsonTree(Map.of(
467 SearchResult.INDEX_ACL, SearchResult.dehydrateAclForIndex(seriesAcl),
468 SearchResult.MODIFIED_DATE, deletionString));
469 var updateRequest = new UpdateRequest(INDEX_NAME, seriesId).doc(gson.toJson(json), XContentType.JSON);
470 try {
471 esIndex.getClient().update(updateRequest, RequestOptions.DEFAULT);
472 } catch (ElasticsearchStatusException e) {
473 if (RestStatus.NOT_FOUND == e.status()) {
474 logger.warn("Attempted to modify {}, but that series does not exist in the index.", seriesId);
475 }
476 }
477 } else {
478
479 deleteSeriesSynchronously(seriesId);
480 }
481 } catch (IOException e) {
482 throw new SearchException(e);
483 }
484 }
485
486 return true;
487 } catch (SearchServiceDatabaseException e) {
488 logger.info("Could not delete media package with id {} from search index", mediaPackageId);
489 throw new SearchException(e);
490 }
491 }
492
493
494
495
496
497
498
499
500 public boolean deleteSeriesSynchronously(String seriesId) throws SearchException {
501 try {
502 logger.info("Marking {} as deleted in the search index", seriesId);
503 JsonElement json = gson.toJsonTree(Map.of(
504 "deleted", Instant.now().getEpochSecond(),
505 "modified", Instant.now().toString()));
506 var updateRequest = new UpdateRequest(INDEX_NAME, seriesId).doc(gson.toJson(json), XContentType.JSON);
507 try {
508 UpdateResponse response = esIndex.getClient().update(updateRequest, RequestOptions.DEFAULT);
509
510 return DocWriteResponse.Result.UPDATED == response.getResult();
511 } catch (ElasticsearchStatusException e) {
512 if (RestStatus.NOT_FOUND == e.status()) {
513 logger.debug("Attempted to delete {}, but that series does not exist in the index.", seriesId);
514 return true;
515 }
516 throw new SearchException(e);
517 }
518 } catch (IOException e) {
519 throw new SearchException("Could not delete series " + seriesId + " from index", e);
520 }
521 }
522
523 @Override
524 public void repopulate(IndexRebuildService.DataType type) throws IndexRebuildException {
525 final Organization originalOrg = securityService.getOrganization();
526 final User originalUser = securityService.getUser();
527
528 try {
529 int total = persistence.countMediaPackages();
530 int pageSize = 50;
531 int pageOffset = 0;
532 AtomicInteger current = new AtomicInteger(1);
533 logIndexRebuildBegin(logger, total, "search");
534 List<Tuple<MediaPackage, String>> page = null;
535
536 do {
537 page = persistence.getAllMediaPackages(pageSize, pageOffset).collect(Collectors.toList());
538 page.forEach(tuple -> {
539 try {
540 MediaPackage mediaPackage = tuple.getA();
541 Organization organization = organizationDirectory.getOrganization(tuple.getB());
542 final var systemUser = SecurityUtil.createSystemUser(systemUserName, organization);
543 securityService.setUser(systemUser);
544 securityService.setOrganization(organization);
545
546 String mediaPackageId = mediaPackage.getIdentifier().toString();
547
548 AccessControlList acl = persistence.getAccessControlList(mediaPackageId);
549 Date modificationDate = persistence.getModificationDate(mediaPackageId);
550 Date deletionDate = persistence.getDeletionDate(mediaPackageId);
551
552 current.getAndIncrement();
553
554 indexMediaPackage(mediaPackage, acl, modificationDate, deletionDate);
555 } catch (SearchServiceDatabaseException e) {
556 logIndexRebuildError(logger, total, current.get(), e);
557
558 throw new RuntimeException("Internal Index Rebuild Failure", e);
559 } catch (RuntimeException | NotFoundException e) {
560 logSkippingElement(logger, "event", tuple.getA().getIdentifier().toString(), e);
561 }
562 });
563
564 logIndexRebuildProgress(logger, total, current.get() - 1, pageSize);
565 pageOffset += 1;
566 } while (pageOffset * pageSize <= total);
567
568 } catch (SearchServiceDatabaseException | RuntimeException e) {
569 logIndexRebuildError(logger, e);
570 throw new IndexRebuildException("Index Rebuild Failure", e);
571 } finally {
572 securityService.setUser(originalUser);
573 securityService.setOrganization(originalOrg);
574 }
575 }
576
577 @Reference
578 public void setPersistence(SearchServiceDatabase persistence) {
579 this.persistence = persistence;
580 }
581
582 @Reference
583 public void setSeriesService(SeriesService seriesService) {
584 this.seriesService = seriesService;
585 }
586
587 @Reference
588 public void setWorkspace(Workspace workspace) {
589 this.workspace = workspace;
590 }
591
592 @Reference
593 public void setAuthorizationService(AuthorizationService authorizationService) {
594 this.authorizationService = authorizationService;
595 }
596
597
598
599
600
601
602
603 @Reference
604 public void setSecurityService(SecurityService securityService) {
605 this.securityService = securityService;
606 }
607
608
609
610
611
612
613
614 @Reference
615 public void setOrganizationDirectoryService(OrganizationDirectoryService organizationDirectory) {
616 this.organizationDirectory = organizationDirectory;
617 }
618
619 @Reference
620 public void setListProvidersService(ListProvidersService listProvidersService) {
621 this.listProvidersService = listProvidersService;
622 }
623 }