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.authorization.xacml;
23  
24  import static org.opencastproject.mediapackage.MediaPackageElements.XACML_POLICY_EPISODE;
25  import static org.opencastproject.mediapackage.MediaPackageElements.XACML_POLICY_SERIES;
26  import static org.opencastproject.security.util.SecurityUtil.getEpisodeRoleId;
27  import static org.opencastproject.util.data.Tuple.tuple;
28  
29  import org.opencastproject.mediapackage.Attachment;
30  import org.opencastproject.mediapackage.MediaPackage;
31  import org.opencastproject.mediapackage.MediaPackageElementBuilderFactory;
32  import org.opencastproject.mediapackage.MediaPackageElementFlavor;
33  import org.opencastproject.mediapackage.MediaPackageException;
34  import org.opencastproject.mediapackage.MediaPackageSerializer;
35  import org.opencastproject.security.api.AccessControlEntry;
36  import org.opencastproject.security.api.AccessControlList;
37  import org.opencastproject.security.api.AclScope;
38  import org.opencastproject.security.api.AuthorizationService;
39  import org.opencastproject.security.api.Role;
40  import org.opencastproject.security.api.SecurityService;
41  import org.opencastproject.security.api.User;
42  import org.opencastproject.util.MimeTypes;
43  import org.opencastproject.util.NotFoundException;
44  import org.opencastproject.util.data.Tuple;
45  import org.opencastproject.workspace.api.Workspace;
46  
47  import org.apache.commons.io.IOUtils;
48  import org.apache.commons.lang3.StringUtils;
49  import org.osgi.service.component.ComponentContext;
50  import org.osgi.service.component.annotations.Activate;
51  import org.osgi.service.component.annotations.Component;
52  import org.osgi.service.component.annotations.Modified;
53  import org.osgi.service.component.annotations.Reference;
54  import org.osgi.service.component.annotations.ReferenceCardinality;
55  import org.osgi.service.component.annotations.ReferencePolicy;
56  import org.slf4j.Logger;
57  import org.slf4j.LoggerFactory;
58  
59  import java.io.IOException;
60  import java.io.InputStream;
61  import java.net.URI;
62  import java.net.URISyntaxException;
63  import java.util.Arrays;
64  import java.util.Optional;
65  
66  import javax.xml.bind.JAXBException;
67  
68  /**
69   * A XACML implementation of the {@link AuthorizationService}.
70   */
71  @Component(
72      property = {
73          "service.description=Provides translation between access control entries and xacml documents"
74      },
75      service = { AuthorizationService.class }
76  )
77  public class XACMLAuthorizationService implements AuthorizationService {
78  
79    /** The logger */
80    private static final Logger logger = LoggerFactory.getLogger(XACMLAuthorizationService.class);
81  
82    /** The default filename for XACML attachments */
83    private static final String XACML_FILENAME = "xacml.xml";
84  
85    /** The workspace */
86    protected Workspace workspace;
87  
88    /** The security service */
89    protected SecurityService securityService;
90  
91    /** The serializer for media pacakge */
92    private MediaPackageSerializer serializer;
93  
94    private static final String CONFIG_MERGE_MODE = "merge.mode";
95  
96    /** Definition of how merging of series and episode ACLs work */
97    private static MergeMode mergeMode = MergeMode.OVERRIDE;
98  
99    enum MergeMode {
100     OVERRIDE, ROLES, ACTIONS
101   }
102 
103   @Activate
104   @Modified
105   public void activate(ComponentContext cc) {
106     var properties = cc.getProperties();
107 
108     if (properties == null) {
109       mergeMode = MergeMode.OVERRIDE;
110       logger.debug("Merge mode set to {}", mergeMode);
111       logger.debug("Using episode ID roles is deactivated");
112       return;
113     }
114     final String mode = StringUtils.defaultIfBlank((String) properties.get(CONFIG_MERGE_MODE),
115         MergeMode.OVERRIDE.toString());
116     try {
117       mergeMode = MergeMode.valueOf(mode.toUpperCase());
118     } catch (IllegalArgumentException e) {
119       logger.warn("Invalid value set for ACL merge mode, defaulting to {}", MergeMode.OVERRIDE);
120       mergeMode = MergeMode.OVERRIDE;
121     }
122     logger.debug("Merge mode set to {}", mergeMode);
123   }
124 
125   @Reference(
126       cardinality = ReferenceCardinality.OPTIONAL,
127       policy = ReferencePolicy.DYNAMIC,
128       unbind = "unsetMediaPackageSerializer",
129       target = "(service.pid=org.opencastproject.mediapackage.ChainingMediaPackageSerializer)"
130   )
131   public void setMediaPackageSerializer(MediaPackageSerializer serializer) {
132     this.serializer = serializer;
133   }
134 
135   protected void unsetMediaPackageSerializer(MediaPackageSerializer serializer) {
136     if (this.serializer == serializer) {
137       this.serializer = null;
138     }
139   }
140 
141   @Override
142   public Tuple<AccessControlList, AclScope> getActiveAcl(final MediaPackage mp) {
143     logger.debug("getActiveACl for media package {}", mp.getIdentifier());
144     return getAcl(mp, AclScope.Episode);
145   }
146 
147   @Override
148   public Tuple<AccessControlList, AclScope> getAcl(final MediaPackage mp, final AclScope scope) {
149     Optional<AccessControlList> episode = Optional.empty();
150     Optional<AccessControlList> series = Optional.empty();
151 
152     // Start with the requested scope but fall back to the less specific scope if it does not exist.
153     // The order is: episode -> series -> general (deprecated) -> global
154     if (AclScope.Episode.equals(scope) || AclScope.Merged.equals(scope)) {
155       episode = getAclByFlavor(mp, XACML_POLICY_EPISODE);
156     }
157     if (Arrays.asList(AclScope.Episode, AclScope.Series, AclScope.Merged).contains(scope)) {
158       series = getAclByFlavor(mp, XACML_POLICY_SERIES);
159     }
160 
161     if (episode.isPresent() && series.isPresent()) {
162       logger.debug("Found event and series ACL for media package {}", mp.getIdentifier());
163       switch (mergeMode) {
164         case ACTIONS:
165           logger.debug("Merging ACLs based on individual actions");
166           return tuple(series.get().mergeActions(episode.get()), AclScope.Merged);
167         case ROLES:
168           logger.debug("Merging ACLs based on roles");
169           return tuple(series.get().merge(episode.get()), AclScope.Merged);
170         default:
171           logger.debug("Episode ACL overrides series ACL");
172           return tuple(episode.get(), AclScope.Merged);
173       }
174     }
175     if (episode.isPresent()) {
176       logger.debug("Found event ACL for media package {}", mp.getIdentifier());
177       return tuple(episode.get(), AclScope.Episode);
178     }
179     if (series.isPresent()) {
180       logger.debug("Found series ACL for media package {}", mp.getIdentifier());
181       return tuple(series.get(), AclScope.Series);
182     }
183 
184     logger.debug("Falling back to global default ACL");
185     return tuple(new AccessControlList(), AclScope.Global);
186   }
187 
188   private Optional<AccessControlList> getAclByFlavor(MediaPackage mp, MediaPackageElementFlavor xacmlPolicyFlavor) {
189     Optional<AccessControlList> acl = Optional.empty();
190     for (Attachment xacml : mp.getAttachments(xacmlPolicyFlavor)) {
191       URI uri = xacml.getURI();
192       try {
193         if (serializer != null) {
194           uri = serializer.decodeURI(uri);
195         }
196       } catch (URISyntaxException e) {
197         logger.warn("URI {} syntax error, skip decoding", uri);
198       }
199       acl = loadAcl(uri);
200     }
201     return acl;
202   }
203 
204   @Override
205   public Tuple<MediaPackage, Attachment> setAcl(
206       final MediaPackage mp,
207       final AclScope scope,
208       final AccessControlList acl
209   ) throws MediaPackageException {
210     // Get XACML representation of these role + action tuples
211     String xacmlContent;
212     try {
213       xacmlContent = XACMLUtils.getXacml(mp, acl);
214     } catch (JAXBException e) {
215       throw new MediaPackageException("Unable to generate xacml for media package " + mp.getIdentifier());
216     }
217 
218     // Remove the old xacml file(s)
219     Attachment attachment = removeFromMediaPackageAndWorkspace(mp, toFlavor(scope)).getB();
220 
221     // add attachment
222     final String elementId = toElementId(scope);
223     URI uri;
224     try (InputStream in = IOUtils.toInputStream(xacmlContent, "UTF-8")) {
225       uri = workspace.put(mp.getIdentifier().toString(), elementId, XACML_FILENAME, in);
226     } catch (IOException e) {
227       throw new MediaPackageException("Error storing xacml for media package " + mp.getIdentifier());
228     }
229 
230     if (attachment == null) {
231       attachment = (Attachment) MediaPackageElementBuilderFactory.newInstance().newElementBuilder()
232               .elementFromURI(uri, Attachment.TYPE, toFlavor(scope));
233     }
234     attachment.setURI(uri);
235     attachment.setIdentifier(elementId);
236     attachment.setMimeType(MimeTypes.XML);
237     // setting the URI to a new source so the checksum will most like be invalid
238     attachment.setChecksum(null);
239     mp.add(attachment);
240 
241     logger.debug("Saved XACML as {}", uri);
242 
243     // return augmented media package
244     return tuple(mp, attachment);
245   }
246 
247   @Override
248   public MediaPackage removeAcl(MediaPackage mp, AclScope scope) {
249     return removeFromMediaPackageAndWorkspace(mp, toFlavor(scope)).getA();
250   }
251 
252   /** Get the flavor associated with a scope. */
253   private static MediaPackageElementFlavor toFlavor(AclScope scope) {
254     switch (scope) {
255       case Episode:
256         return XACML_POLICY_EPISODE;
257       case Series:
258         return XACML_POLICY_SERIES;
259       default:
260         throw new IllegalArgumentException("No flavors match the given ACL scope");
261     }
262   }
263 
264   /** Get the element id associated with a scope. */
265   private static String toElementId(AclScope scope) {
266     switch (scope) {
267       case Episode:
268         return "security-policy-episode";
269       case Series:
270         return "security-policy-series";
271       default:
272         throw new IllegalArgumentException("No element id matches the given ACL scope");
273     }
274   }
275 
276   /**
277    * Remove all attachments of the given flavors from media package and workspace.
278    *
279    * @return the a tuple with the mutated (!) media package as A and the deleted Attachment as B
280    */
281   private Tuple<MediaPackage, Attachment> removeFromMediaPackageAndWorkspace(MediaPackage mp,
282           MediaPackageElementFlavor flavor) {
283     Attachment attachment = null;
284     for (Attachment a : mp.getAttachments(flavor)) {
285       attachment = (Attachment) a.clone();
286       try {
287         workspace.delete(a.getURI());
288       } catch (Exception e) {
289         logger.warn("Unable to delete XACML file:", e);
290       }
291       mp.remove(a);
292     }
293     return Tuple.tuple(mp, attachment);
294   }
295 
296   /** Load an ACL from the given URI. */
297   private Optional<AccessControlList> loadAcl(final URI uri) {
298     logger.debug("Load Acl from {}", uri);
299     try (InputStream is = workspace.read(uri)) {
300       AccessControlList acl = XACMLUtils.parseXacml(is);
301       return Optional.of(acl);
302     } catch (NotFoundException e) {
303       logger.debug("URI {} not found", uri);
304     } catch (Exception e) {
305       logger.warn("Unable to load or parse Acl from URI {}", uri, e);
306     }
307     return Optional.empty();
308   }
309 
310   public boolean hasPermission(final MediaPackage mp, final String action) {
311     AccessControlList acl = getActiveAcl(mp).getA();
312 
313     // Check special ROLE_EPISODE_<ID>_<ACTION> permissions
314     final User user = securityService.getUser();
315     var episodeRole = getEpisodeRoleId(mp.getIdentifier().toString(), action);
316     logger.debug("Checking for role: {}", episodeRole);
317     var allowed = user.getRoles().stream().map(Role::getName).anyMatch(r -> r.equals(episodeRole));
318 
319     return allowed || hasPermission(acl, action);
320   }
321 
322   @Override
323   public boolean hasPermission(AccessControlList acl, final String action) {
324     final User user = securityService.getUser();
325     var allowed = false;
326 
327     // Check ACL
328     for (AccessControlEntry entry: acl.getEntries()) {
329       // ignore entries for other actions
330       if (!entry.getAction().equals(action)) {
331         continue;
332       }
333       for (Role role : user.getRoles()) {
334         if (entry.getRole().equals(role.getName())) {
335           // immediately abort on matching deny rules
336           // (never allow if a deny rule matches, even if another allow rule matches)
337           if (!entry.isAllow()) {
338             logger.debug("Access explicitly denied for role({}), action({})", role.getName(), action);
339             return false;
340           }
341           allowed = true;
342         }
343       }
344     }
345     logger.debug("XACML file allowed access");
346     return allowed;
347   }
348 
349   /**
350    * Sets the workspace to use for retrieving XACML policies
351    *
352    * @param workspace
353    *          the workspace to set
354    */
355   @Reference
356   public void setWorkspace(Workspace workspace) {
357     this.workspace = workspace;
358   }
359 
360   /**
361    * Declarative services callback to set the security service.
362    *
363    * @param securityService
364    *          the security service
365    */
366   @Reference
367   public void setSecurityService(SecurityService securityService) {
368     this.securityService = securityService;
369   }
370 
371 }