/* * Copyright 2013-2020 Erudika. https://erudika.com * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * For issues and patches go to: https://github.com/erudika */ package com.erudika.para.core; import com.erudika.para.AppCreatedListener; import com.erudika.para.AppDeletedListener; import com.erudika.para.AppSettingAddedListener; import com.erudika.para.AppSettingRemovedListener; import com.erudika.para.core.utils.CoreUtils; import com.erudika.para.core.utils.ParaObjectUtils; import com.erudika.para.annotations.Locked; import com.erudika.para.annotations.Stored; import static com.erudika.para.core.App.AllowedMethods.ALL; import static com.erudika.para.core.App.AllowedMethods.GET; import static com.erudika.para.core.App.AllowedMethods.GUEST; import static com.erudika.para.core.App.AllowedMethods.OWN; import static com.erudika.para.core.App.AllowedMethods.READ; import static com.erudika.para.core.App.AllowedMethods.READ_AND_WRITE; import static com.erudika.para.core.App.AllowedMethods.READ_ONLY; import static com.erudika.para.core.App.AllowedMethods.READ_WRITE; import static com.erudika.para.core.App.AllowedMethods.WRITE; import static com.erudika.para.core.App.AllowedMethods.WRITE_ONLY; import static com.erudika.para.core.App.AllowedMethods.fromString; import com.erudika.para.utils.Config; import com.erudika.para.utils.Pager; import com.erudika.para.utils.Utils; import com.erudika.para.validation.Constraint; import com.erudika.para.validation.ValidationUtils; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonValue; import java.io.Serializable; import java.util.ArrayList; import java.util.Collections; import java.util.EnumSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; import org.apache.commons.collections.bidimap.DualHashBidiMap; import org.apache.commons.lang3.StringUtils; import javax.validation.constraints.NotBlank; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * This is a representation of an application within Para. * <br> * It allows the user to create separate apps running on the same infrastructure. * Every {@link ParaObject} belongs to an app. * <br> * Apps can have a dedicated table or they can share the same table using prefixed keys. * Also, apps can have a dedicated search index or share one. These are controlled by * the two flags {@link #isSharingTable() } and {@link #isSharingIndex() }. * <br> * Usually when we have a multi-app environment there's a parent app (dedicated) and * lots of child apps (shared) that share the same index with the parent app. * * @author Alex Bogdanovski [[email protected]] */ public class App implements ParaObject, Serializable { /** * {@value #APP_ROLE}. */ public static final String APP_ROLE = "ROLE_APP"; /** * {@value #ALLOW_ALL}. */ public static final String ALLOW_ALL = "*"; private static final long serialVersionUID = 1L; private static final String PREFIX = Utils.type(App.class).concat(Config.SEPARATOR); private static final Set<AppCreatedListener> CREATE_LISTENERS = new LinkedHashSet<AppCreatedListener>(); private static final Set<AppDeletedListener> DELETE_LISTENERS = new LinkedHashSet<AppDeletedListener>(); private static final Set<AppSettingAddedListener> ADD_SETTING_LISTENERS = new LinkedHashSet<>(); private static final Set<AppSettingRemovedListener> REMOVE_SETTING_LISTENERS = new LinkedHashSet<>(); private static final Logger logger = LoggerFactory.getLogger(App.class); @Stored @Locked @NotBlank private String id; @Stored @Locked private Long timestamp; @Stored @Locked private String type; @Stored @Locked private String appid; @Stored @Locked private String parentid; @Stored @Locked private String creatorid; @Stored private Long updated; @Stored private String name; @Stored private List<String> tags; @Stored private Integer votes; @Stored private Long version; @Stored private Boolean stored; @Stored private Boolean indexed; @Stored private Boolean cached; @Stored @Locked private boolean sharingIndex; @Stored @Locked private boolean sharingTable; @Stored @Locked private String secret; @Stored @Locked private Boolean readOnly; @Stored private Map<String, String> datatypes; // type -> field -> constraint -> property -> value @Stored private Map<String, Map<String, Map<String, Map<String, ?>>>> validationConstraints; // subject_id -> resource_name -> [http_methods_allowed] @Stored private Map<String, Map<String, List<String>>> resourcePermissions; @Stored private Boolean active; @Stored private Long deleteOn; @Stored private Long tokenValiditySec; // used to store various settings, OAuth keys, etc. @Stored private Map<String, Object> settings; /** * No-args constructor. */ public App() { this(null); } /** * Default constructor. * @param id the name of the app */ public App(String id) { this.active = true; this.readOnly = false; setId(id); setName(getName()); this.sharingIndex = !isRoot(getId()); this.sharingTable = false; } /** * Returns the correct id of this app with prefix. * @param id an id like "myapp" * @return the full id, e.g. "app:myapp" */ public static final String id(String id) { if (StringUtils.startsWith(id, PREFIX)) { return PREFIX.concat(Utils.noSpaces(Utils.stripAndTrim(id.replaceAll(PREFIX, ""), " "), "-")); } else if (id != null) { return PREFIX.concat(Utils.noSpaces(Utils.stripAndTrim(id, " "), "-")); } else { return null; } } /** * Returns the identifier without the "app:" prefix. * @param appid app id * @return just the name of the app */ public static final String identifier(String appid) { return (appid != null) ? appid.replaceFirst(PREFIX, "") : ""; } @Override public final void setId(String id) { this.id = id(id); } /** * Adds a new setting to the map. * @param name a key * @param value a value * @return this */ public App addSetting(String name, Object value) { if (!StringUtils.isBlank(name) && value != null) { getSettings().put(name, value); for (AppSettingAddedListener listener : ADD_SETTING_LISTENERS) { listener.onSettingAdded(this, name, value); logger.debug("Executed {}.onSettingAdded().", listener.getClass().getName()); } } return this; } /** * Adds all settings to map of app settings and invokes all {@link AppSettingAddedListener}s. * @param settings a map settings to add * @return this */ public App addAllSettings(Map<String, Object> settings) { // add the new settings one at a time so the add setting listeners are invoked if (settings != null && !settings.isEmpty()) { for (Map.Entry<String, Object> iter : settings.entrySet()) { addSetting(iter.getKey(), iter.getValue()); } } return this; } /** * Returns the value of a setting for a given key. * @param name the key * @return the value */ public Object getSetting(String name) { if (!StringUtils.isBlank(name)) { return getSettings().get(name); } return null; } /** * Removes a setting from the map. * @param name the key * @return this */ public App removeSetting(String name) { if (!StringUtils.isBlank(name)) { Object result = getSettings().remove(name); if (result != null) { for (AppSettingRemovedListener listener : REMOVE_SETTING_LISTENERS) { listener.onSettingRemoved(this, name); logger.debug("Executed {}.onSettingRemoved().", listener.getClass().getName()); } } } return this; } /** * Clears all app settings and invokes each {@link AppSettingRemovedListener}s. * @return this */ public App clearSettings() { // remove the old settings one at a time so the remove setting listeners are invoked if (settings != null && !settings.isEmpty()) { List<String> keysToRemove = settings.keySet().stream().collect(Collectors.toList()); keysToRemove.stream().forEach(oldKey -> removeSetting(oldKey)); } return this; } /** * A map of all settings (key/values). * @return a map */ @JsonIgnore public Map<String, Object> getSettings() { if (settings == null) { settings = new LinkedHashMap<>(); } return settings; } /** * Overwrites the settings map. * @param settings a new map */ public void setSettings(Map<String, Object> settings) { this.settings = settings; } /** * Returns a map of user-defined data types and their validation annotations. * @return the constraints map */ public Map<String, Map<String, Map<String, Map<String, ?>>>> getValidationConstraints() { if (validationConstraints == null) { validationConstraints = new LinkedHashMap<>(); } return validationConstraints; } /** * Sets the validation constraints map. * @param validationConstraints the constraints map */ public void setValidationConstraints(Map<String, Map<String, Map<String, Map<String, ?>>>> validationConstraints) { this.validationConstraints = validationConstraints; } /** * Returns a map of resource permissions. * @return the permissions map */ public Map<String, Map<String, List<String>>> getResourcePermissions() { if (resourcePermissions == null) { resourcePermissions = new LinkedHashMap<>(); } return resourcePermissions; } /** * Sets the permissions map. * @param resourcePermissions permissions map */ public void setResourcePermissions(Map<String, Map<String, List<String>>> resourcePermissions) { this.resourcePermissions = resourcePermissions; } /** * The App identifier (the id but without the prefix 'app:'). * The identifier may start with a whitespace character e.g. " myapp". * This indicates that the app is sharing a table with other apps. * This is disabled by default unless 'para.prepend_shared_appids_with_space = true' * @return the identifier (appid) */ public String getAppIdentifier() { String pre = isSharingTable() && Config.getConfigBoolean("prepend_shared_appids_with_space", false) ? " " : ""; return (getId() != null) ? getId().replaceFirst(PREFIX, pre) : ""; } /** * Returns true if this application is active (enabled). * @return true if active */ public Boolean getActive() { return active; } /** * Sets the active flag. When an app is disabled (active = false) * it cannot be accessed through the API. * @param active true if active */ public void setActive(Boolean active) { this.active = active; } /** * The timestamp for when this app must be deleted. * @return a timestamp */ public Long getDeleteOn() { return deleteOn; } /** * Sets the time for deletion. * @param deleteOn a timestamp */ public void setDeleteOn(Long deleteOn) { this.deleteOn = deleteOn; } /** * The validity period for access tokens in seconds. * This setting is for current app only. * Always returns a default value if local setting is null. * @return period in seconds */ public Long getTokenValiditySec() { if (tokenValiditySec == null || tokenValiditySec <= 0) { tokenValiditySec = (long) Config.JWT_EXPIRES_AFTER_SEC; } return tokenValiditySec; } /** * Sets the access token validity period in seconds. * @param tokenValiditySec seconds */ public void setTokenValiditySec(Long tokenValiditySec) { if (tokenValiditySec == null || tokenValiditySec <= 0) { this.tokenValiditySec = 0L; } this.tokenValiditySec = tokenValiditySec; } /** * Returns the app's secret key. * @return the secret key */ @JsonIgnore public String getSecret() { return secret; } /** * Sets the secret key. * @param secret a secret key */ public void setSecret(String secret) { this.secret = secret; } /** * Gets read-only mode. * @return true if app is in read-only mode */ public Boolean getReadOnly() { return readOnly; } /** * Sets read-only mode. * @param readOnly true if app is in read-only mode */ public void setReadOnly(Boolean readOnly) { this.readOnly = readOnly; } /** * Returns a set of custom data types for this app. * An app can have many custom types which describe its domain. * @return a map of type names (plural form to singular) */ @SuppressWarnings("unchecked") public Map<String, String> getDatatypes() { if (datatypes == null) { datatypes = new DualHashBidiMap(); } return datatypes; } /** * Sets the data types for this app. * @param datatypes a map of type names (plural form to singular) */ public void setDatatypes(Map<String, String> datatypes) { this.datatypes = datatypes; } /** * Is this a sharing the search index with other apps. * @return true if it does */ public boolean isSharingIndex() { return isRoot(getId()) ? false : sharingIndex; } /** * Sets the sharingIndex flag. * @param sharingIndex false means this app should have its own dedicated index */ public void setSharingIndex(boolean sharingIndex) { this.sharingIndex = sharingIndex; } /** * Is this a sharing the database table with other apps. * @return true if it does */ public boolean isSharingTable() { return sharingTable; } /** * Sets the sharingTable flag. * @param sharingTable false means this app should have its own dedicated table */ public void setSharingTable(boolean sharingTable) { this.sharingTable = sharingTable; } /** * Return true if the app is the root app (the first one created). * @return true if root */ @JsonIgnore public boolean isRootApp() { return StringUtils.equals(id(Config.getRootAppIdentifier()), getId()); } /** * Return true if the app is the root app (the first one created). * @param appid an app identifier * @return true if root */ public static boolean isRoot(String appid) { return StringUtils.equals(id(Config.getRootAppIdentifier()), id(appid)); } /** * Returns all validation constraints for a list of types. * @param types a list of valid Para data types * @return a map of validation constraints for given types */ public Map<String, Map<String, Map<String, Map<String, ?>>>> getAllValidationConstraints(String... types) { Map<String, Map<String, Map<String, Map<String, ?>>>> allConstr = new LinkedHashMap<>(); if (types == null || types.length == 0) { types = ParaObjectUtils.getAllTypes(this).values().toArray(new String[0]); } try { for (String aType : types) { Map<String, Map<String, Map<String, ?>>> vc = new LinkedHashMap<>(); // add all core constraints first if (ValidationUtils.getCoreValidationConstraints().containsKey(aType)) { vc.putAll(ValidationUtils.getCoreValidationConstraints().get(aType)); } // also add the ones that are defined locally for this app Map<String, Map<String, Map<String, ?>>> appConstraints = getValidationConstraints().get(aType); if (appConstraints != null && !appConstraints.isEmpty()) { vc.putAll(appConstraints); } if (!vc.isEmpty()) { allConstr.put(aType, vc); } } } catch (Exception ex) { logger.error(null, ex); } return allConstr; } /** * Adds a new constraint to the list of constraints for a given field and type. * @param type the type * @param field the field * @param c the constraint * @return true if successful */ public boolean addValidationConstraint(String type, String field, Constraint c) { if (!StringUtils.isBlank(type) && !StringUtils.isBlank(field) && c != null && !c.getPayload().isEmpty() && Constraint.isValidConstraintName(c.getName())) { Map<String, Map<String, Map<String, ?>>> fieldMap = getValidationConstraints().get(type); Map<String, Map<String, ?>> consMap; if (fieldMap != null) { consMap = fieldMap.get(field); if (consMap == null) { consMap = new LinkedHashMap<>(); } } else { fieldMap = new LinkedHashMap<>(); consMap = new LinkedHashMap<>(); } consMap.put(c.getName(), c.getPayload()); fieldMap.put(field, consMap); getValidationConstraints().put(type, fieldMap); addDatatype(Utils.singularToPlural(type), type); return true; } return false; } /** * Removes a constraint from the map. * @param type the type * @param field the field * @param constraintName the constraint name * @return true if successful */ public boolean removeValidationConstraint(String type, String field, String constraintName) { if (!StringUtils.isBlank(type) && !StringUtils.isBlank(field) && constraintName != null) { Map<String, Map<String, Map<String, ?>>> fieldsMap = getValidationConstraints().get(type); if (fieldsMap != null && fieldsMap.containsKey(field)) { if (fieldsMap.get(field).containsKey(constraintName)) { fieldsMap.get(field).remove(constraintName); } if (fieldsMap.get(field).isEmpty()) { getValidationConstraints().get(type).remove(field); } if (getValidationConstraints().get(type).isEmpty()) { getValidationConstraints().remove(type); } return true; } } return false; } /** * Returns all resource permission for a list of subjects ids. * @param subjectids subject ids (user ids) * @return a map of all resource permissions per subject */ public Map<String, Map<String, List<String>>> getAllResourcePermissions(String... subjectids) { Map<String, Map<String, List<String>>> allPermits = new LinkedHashMap<>(); if (subjectids == null || subjectids.length == 0) { return getResourcePermissions(); } try { for (String subjectid : subjectids) { if (subjectid != null) { if (getResourcePermissions().containsKey(subjectid)) { allPermits.put(subjectid, getResourcePermissions().get(subjectid)); } else { allPermits.put(subjectid, new LinkedHashMap<>(0)); } if (getResourcePermissions().containsKey(ALLOW_ALL)) { allPermits.put(ALLOW_ALL, getResourcePermissions().get(ALLOW_ALL)); } } } } catch (Exception ex) { logger.error(null, ex); } return allPermits; } /** * Grants a new permission for a given subject and resource. * @param subjectid the subject to give permissions to * @param resourcePath the resource name/type * @param permission the set or HTTP methods allowed * @return true if successful */ public boolean grantResourcePermission(String subjectid, String resourcePath, EnumSet<AllowedMethods> permission) { return grantResourcePermission(subjectid, resourcePath, permission, false); } /** * Grants a new permission for a given subject and resource. * @param subjectid the subject to give permissions to * @param resourcePath the resource name/type * @param permission the set or HTTP methods allowed * @param allowGuestAccess if true - all unauthenticated requests will go through, 'false' by default. * @return true if successful */ public boolean grantResourcePermission(String subjectid, String resourcePath, EnumSet<AllowedMethods> permission, boolean allowGuestAccess) { // urlDecode resource path & strip slashes at both ends resourcePath = Utils.urlDecode(resourcePath); resourcePath = StringUtils.removeEnd(resourcePath, "/"); resourcePath = StringUtils.removeStart(resourcePath, "/"); resourcePath = StringUtils.remove(resourcePath, "."); // Elasticsearch 2.0+ restriction if (!StringUtils.isBlank(subjectid) && !StringUtils.isBlank(resourcePath) && permission != null && !permission.isEmpty()) { allowGuestAccess = permission.remove(GUEST) || allowGuestAccess; EnumSet<AllowedMethods> methods = getAllowedMethodsSet(permission); if (!getResourcePermissions().containsKey(subjectid)) { Map<String, List<String>> perm = new LinkedHashMap<>(); perm.put(resourcePath, new ArrayList<>(permission.size())); getResourcePermissions().put(subjectid, perm); } if (allowGuestAccess && ALLOW_ALL.equals(subjectid)) { methods.add(GUEST); } if (permission.contains(OWN)) { methods.add(OWN); // limits access to objects created by the user } List<String> perm = new ArrayList<>(methods.size()); for (AllowedMethods allowedMethod : methods) { perm.add(allowedMethod.toString()); } getResourcePermissions().get(subjectid).put(resourcePath, perm); String typ = resourcePath.split("\\/")[0]; addDatatype(Utils.singularToPlural(typ), typ); return true; } return false; } private EnumSet<AllowedMethods> getAllowedMethodsSet(EnumSet<AllowedMethods> permission) { if (permission == null) { return EnumSet.copyOf(AllowedMethods.NONE); } EnumSet<AllowedMethods> methods = EnumSet.copyOf(permission); if (isAllowAllPermission(permission)) { methods = EnumSet.copyOf(READ_AND_WRITE); } else if (permission.contains(WRITE_ONLY)) { methods = EnumSet.copyOf(WRITE); } else if (permission.contains(READ_ONLY)) { methods = EnumSet.copyOf(READ); } return methods; } private boolean isAllowAllPermission(EnumSet<AllowedMethods> permission) { return permission != null && (permission.containsAll(ALL) || permission.contains(READ_WRITE) || // * || rw = * (permission.contains(READ_ONLY) && permission.contains(WRITE_ONLY)) || // r + w = * (permission.contains(GET) && permission.contains(WRITE_ONLY))); // r + w = * } /** * Revokes a permission for given subject. * @param subjectid subject id * @param resourcePath resource path or object type * @return true if successful */ public boolean revokeResourcePermission(String subjectid, String resourcePath) { if (!StringUtils.isBlank(subjectid) && getResourcePermissions().containsKey(subjectid) && !StringUtils.isBlank(resourcePath)) { // urlDecode resource path resourcePath = Utils.urlDecode(resourcePath); getResourcePermissions().get(subjectid).remove(resourcePath); if (getResourcePermissions().get(subjectid).isEmpty()) { getResourcePermissions().remove(subjectid); } return true; } return false; } /** * Revokes all permissions for a subject id. * @param subjectid subject id * @return true if successful */ public boolean revokeAllResourcePermissions(String subjectid) { if (!StringUtils.isBlank(subjectid) && getResourcePermissions().containsKey(subjectid)) { getResourcePermissions().remove(subjectid); return true; } return false; } /** * Checks if a subject is allowed to call method X on resource Y. * @param subjectid subject id * @param resourcePath resource path or object type * @param httpMethod HTTP method name * @return true if allowed */ public boolean isAllowedTo(String subjectid, String resourcePath, String httpMethod) { boolean allow = false; if (subjectid != null && !StringUtils.isBlank(resourcePath) && !StringUtils.isBlank(httpMethod)) { // urlDecode resource path resourcePath = Utils.urlDecode(resourcePath); if (getResourcePermissions().isEmpty()) { // Default policy is "deny all". Returning true here would make it "allow all". return false; } if (isDeniedExplicitly(subjectid, resourcePath, httpMethod)) { return false; } if (getResourcePermissions().containsKey(subjectid) && getResourcePermissions().get(subjectid).containsKey(resourcePath)) { // subject-specific permissions have precedence over wildcard permissions // i.e. only the permissions for that subjectid are checked, other permissions are ignored allow = isAllowed(subjectid, resourcePath, httpMethod); } else { allow = isAllowed(subjectid, resourcePath, httpMethod) || isAllowed(subjectid, ALLOW_ALL, httpMethod) || isAllowed(ALLOW_ALL, resourcePath, httpMethod) || isAllowed(ALLOW_ALL, ALLOW_ALL, httpMethod); } } if (allow) { if (isRootApp() && !Config.getConfigBoolean("clients_can_access_root_app", false)) { return false; } if (StringUtils.isBlank(subjectid)) { // guest access check return isAllowed(ALLOW_ALL, resourcePath, GUEST.toString()); } return true; } return isAllowedImplicitly(subjectid, resourcePath, httpMethod); } final boolean isAllowed(String subjectid, String resourcePath, String httpMethod) { boolean allowed = false; if (subjectid != null && resourcePath != null && getResourcePermissions().containsKey(subjectid)) { httpMethod = StringUtils.upperCase(httpMethod); String wildcard = ALLOW_ALL; String exactPathToMatch = resourcePath; if (fromString(httpMethod) == GUEST) { // special case where we have wildcard permissions * but public access is not allowed wildcard = httpMethod; } if (StringUtils.contains(resourcePath, '/')) { // we assume that a full resource path is given like: 'users/something/123' // so we check to see if 'users/something' is in the list of resources. // we don't want 'users/someth' to match, but only the exact full path String fragment = resourcePath.substring(0, resourcePath.lastIndexOf('/')); for (String resource : getResourcePermissions().get(subjectid).keySet()) { if (StringUtils.startsWith(fragment, resource) && pathMatches(subjectid, resource, httpMethod, wildcard)) { allowed = true; break; } // allow basic wildcard matching if (StringUtils.endsWith(resource, "/*") && resourcePath.startsWith(resource.substring(0, resource.length() - 1))) { exactPathToMatch = resource; break; } } } if (!allowed && getResourcePermissions().get(subjectid).containsKey(exactPathToMatch)) { // check if exact resource path is accessible allowed = pathMatches(subjectid, exactPathToMatch, httpMethod, wildcard); } else if (!allowed && getResourcePermissions().get(subjectid).containsKey(ALLOW_ALL)) { // check if ALL resources are accessible allowed = pathMatches(subjectid, ALLOW_ALL, httpMethod, wildcard); } } return allowed; } private boolean pathMatches(String subjectid, String path, String httpMethod, String wildcard) { return (getResourcePermissions().getOrDefault(subjectid, Collections.emptyMap()). getOrDefault(path, Collections.emptyList()).contains(httpMethod) || getResourcePermissions().getOrDefault(subjectid, Collections.emptyMap()). getOrDefault(path, Collections.emptyList()).contains(wildcard)); } /** * Check if a subject is explicitly denied access to a resource. * @param subjectid subject id * @param resourcePath resource path or object type * @param httpMethod HTTP method name * @return true if access is explicitly denied */ final boolean isDeniedExplicitly(String subjectid, String resourcePath, String httpMethod) { if (StringUtils.isBlank(subjectid) || StringUtils.isBlank(resourcePath) || StringUtils.isBlank(httpMethod) || getResourcePermissions().isEmpty()) { return false; } // urlDecode resource path resourcePath = Utils.urlDecode(resourcePath); if (getResourcePermissions().containsKey(subjectid)) { if (getResourcePermissions().get(subjectid).containsKey(resourcePath)) { return !isAllowed(subjectid, resourcePath, httpMethod); } else if (getResourcePermissions().get(subjectid).containsKey(ALLOW_ALL)) { return !isAllowed(subjectid, ALLOW_ALL, httpMethod); } } return false; } /** * Check if a request comes from a signed in user who try to read/update themselves. * @param subjectid subject id * @param resourcePath resource path or object type * @param httpMethod HTTP method name * @return true if request is GET, PATCH or PUT and the subject id matches the object id */ final boolean isAllowedImplicitly(String subjectid, String resourcePath, String httpMethod) { if (StringUtils.isBlank(subjectid) || StringUtils.isBlank(resourcePath) || StringUtils.isBlank(httpMethod)) { return false; } if (resourcePath.endsWith("/" + subjectid)) { // implicit permissions: a user can read/update/delete itself return (httpMethod.equals("GET") || httpMethod.equals("PATCH") || httpMethod.equals("DELETE")); } return false; } /** * Check if the permissions map contains "OWN" keyword, which restricts access to objects to their creators. * * @param user user in context * @param object some object * @return true if app contains permission for this resource and it is marked with "OWN" */ public boolean permissionsContainOwnKeyword(User user, ParaObject object) { if (user == null || object == null) { return false; } String resourcePath1 = object.getType(); String resourcePath2 = object.getObjectURI().substring(1); // remove first '/' String resourcePath3 = object.getPlural(); return hasOwnKeyword(App.ALLOW_ALL, resourcePath1) || hasOwnKeyword(App.ALLOW_ALL, resourcePath2) || hasOwnKeyword(App.ALLOW_ALL, resourcePath3) || hasOwnKeyword(user.getId(), resourcePath1) || hasOwnKeyword(user.getId(), resourcePath2) || hasOwnKeyword(user.getId(), resourcePath3); } /** * @param subjectid id of user * @param resourcePath path * @return true if app contains permission for this resource path and it is marked with "OWN" */ final boolean hasOwnKeyword(String subjectid, String resourcePath) { if (subjectid == null || resourcePath == null) { return false; } return getResourcePermissions().containsKey(subjectid) && getResourcePermissions().get(subjectid).containsKey(resourcePath) && getResourcePermissions(). get(subjectid). get(resourcePath). contains(App.AllowedMethods.OWN.toString()); } /** * Adds a user-defined data type to the types map. * @param pluralDatatype the plural form of the type * @param datatype a datatype, must not be null or empty */ public void addDatatype(String pluralDatatype, String datatype) { pluralDatatype = Utils.noSpaces(Utils.stripAndTrim(pluralDatatype, " "), "-"); datatype = Utils.noSpaces(Utils.stripAndTrim(datatype, " "), "-"); if (StringUtils.isBlank(pluralDatatype) || StringUtils.isBlank(datatype)) { return; } if (getDatatypes().size() >= Config.MAX_DATATYPES_PER_APP) { LoggerFactory.getLogger(App.class).warn("Maximum number of types per app reached - {}.", Config.MAX_DATATYPES_PER_APP); return; } if (!getDatatypes().containsKey(pluralDatatype) && !getDatatypes().containsValue(datatype) && !ParaObjectUtils.getCoreTypes().containsKey(pluralDatatype)) { getDatatypes().put(pluralDatatype, datatype); } } /** * Adds unknown types to this app's list of data types. Called on create(). * @param objects a list of new objects */ public void addDatatypes(ParaObject... objects) { // register a new data type if (objects != null && objects.length > 0) { for (ParaObject obj : objects) { if (obj != null && obj.getType() != null) { addDatatype(obj.getPlural(), obj.getType()); } } } } /** * Removes a datatype from the types map. * @param pluralDatatype a datatype, must not be null or empty */ public void removeDatatype(String pluralDatatype) { if (!StringUtils.isBlank(pluralDatatype)) { getDatatypes().remove(pluralDatatype); } } /** * Resets the secret key by generating a new one. */ public void resetSecret() { secret = Utils.generateSecurityToken(40); } /** * Returns the map containing the app's access key and secret key. * @return a map of API keys (never null) */ @JsonIgnore public Map<String, String> getCredentials() { if (getId() == null) { return Collections.emptyMap(); } else { Map<String, String> keys = new LinkedHashMap<String, String>(2); keys.put("accessKey", getId()); keys.put("secretKey", getSecret()); return keys; } } /** * Registers a new create listener. * @param listener the listener */ public static void addAppCreatedListener(AppCreatedListener listener) { if (listener != null) { CREATE_LISTENERS.add(listener); } } /** * Registers a new delete listener. * @param listener the listener */ public static void addAppDeletedListener(AppDeletedListener listener) { if (listener != null) { DELETE_LISTENERS.add(listener); } } /** * Registers a new app setting added listener. * @param listener the listener */ public static void addAppSettingAddedListener(AppSettingAddedListener listener) { if (listener != null) { ADD_SETTING_LISTENERS.add(listener); } } /** * Registers a new app setting removed listener. * @param listener the listener */ public static void addAppSettingRemovedListener(AppSettingRemovedListener listener) { if (listener != null) { REMOVE_SETTING_LISTENERS.add(listener); } } @Override public String create() { if (getId() != null && this.exists()) { return null; } if (!isRoot(getAppid())) { // third level apps not allowed logger.error("Child apps cannot contain app objects."); return null; } if (StringUtils.isBlank(secret)) { resetSecret(); } String appId = CoreUtils.getInstance().getDao().create(getAppid(), this); if (!isRootApp()) { for (AppCreatedListener listener : CREATE_LISTENERS) { listener.onAppCreated(this); logger.debug("Executed {}.onAppCreated().", listener.getClass().getName()); } } return appId; } @Override public void delete() { // root app cannot be deleted if (!isRootApp()) { CoreUtils.getInstance().getDao().delete(getAppid(), this); logger.info("App '{}' deleted.", getId()); for (AppDeletedListener listener : DELETE_LISTENERS) { listener.onAppDeleted(this); logger.info("Executed {}.onAppDeleted().", listener.getClass().getName()); } clearSettings(); } } //////////////////////////////////////////////////////// @Override public final String getId() { return id; } @Override public final String getType() { type = (type == null) ? Utils.type(this.getClass()) : type; return type; } @Override public final void setType(String type) { this.type = type; } @Override public String getAppid() { appid = (appid == null) ? Config.getRootAppIdentifier() : appid; return appid; } @Override public void setAppid(String appid) { this.appid = appid; } @Override public String getObjectURI() { return CoreUtils.getInstance().getObjectURI(this); } @Override public List<String> getTags() { return tags; } @Override public void setTags(List<String> tags) { this.tags = tags; } @Override public Boolean getStored() { if (stored == null) { stored = true; } return stored; } @Override public void setStored(Boolean stored) { this.stored = stored; } @Override public Boolean getIndexed() { if (indexed == null) { indexed = true; } return indexed; } @Override public void setIndexed(Boolean indexed) { this.indexed = indexed; } @Override public Boolean getCached() { if (cached == null) { cached = true; } return cached; } @Override public void setCached(Boolean cached) { this.cached = cached; } @Override public Long getTimestamp() { return (timestamp != null && timestamp != 0) ? timestamp : null; } @Override public void setTimestamp(Long timestamp) { this.timestamp = timestamp; } @Override public String getCreatorid() { return creatorid; } @Override public void setCreatorid(String creatorid) { this.creatorid = creatorid; } @Override public final String getName() { return CoreUtils.getInstance().getName(name, id); } @Override public final void setName(String name) { this.name = (name == null || !name.isEmpty()) ? name : this.name; } @Override public String getPlural() { return Utils.singularToPlural(getType()); } @Override public String getParentid() { return parentid; } @Override public void setParentid(String parentid) { this.parentid = parentid; } @Override public Long getUpdated() { return (updated != null && updated != 0) ? updated : null; } @Override public void setUpdated(Long updated) { this.updated = updated; } @Override public void update() { CoreUtils.getInstance().getDao().update(getAppid(), this); } @Override public boolean exists() { return CoreUtils.getInstance().getDao().read(getAppid(), getId()) != null; } @Override public boolean voteUp(String userid) { return CoreUtils.getInstance().vote(this, userid, VoteValue.UP); } @Override public boolean voteDown(String userid) { return CoreUtils.getInstance().vote(this, userid, VoteValue.DOWN); } @Override public Integer getVotes() { return (votes == null) ? 0 : votes; } @Override public void setVotes(Integer votes) { this.votes = votes; } @Override public Long getVersion() { return (version == null) ? 0 : version; } @Override public void setVersion(Long version) { this.version = version; } @Override public Long countLinks(String type2) { return CoreUtils.getInstance().countLinks(this, type2); } @Override public List<Linker> getLinks(String type2, Pager... pager) { return CoreUtils.getInstance().getLinks(this, type2, pager); } @Override public <P extends ParaObject> List<P> getLinkedObjects(String type, Pager... pager) { return CoreUtils.getInstance().getLinkedObjects(this, type, pager); } @Override public <P extends ParaObject> List<P> findLinkedObjects(String type, String field, String query, Pager... pager) { return CoreUtils.getInstance().findLinkedObjects(this, type, field, query, pager); } @Override public boolean isLinked(String type2, String id2) { return CoreUtils.getInstance().isLinked(this, type2, id2); } @Override public boolean isLinked(ParaObject toObj) { return CoreUtils.getInstance().isLinked(this, toObj); } @Override public String link(String id2) { return CoreUtils.getInstance().link(this, id2); } @Override public void unlink(String type, String id2) { CoreUtils.getInstance().unlink(this, type, id2); } @Override public void unlinkAll() { CoreUtils.getInstance().unlinkAll(this); } @Override public Long countChildren(String type) { return CoreUtils.getInstance().countChildren(this, type); } @Override public <P extends ParaObject> List<P> getChildren(String type, Pager... pager) { return CoreUtils.getInstance().getChildren(this, type, pager); } @Override public <P extends ParaObject> List<P> getChildren(String type, String field, String term, Pager... pager) { return CoreUtils.getInstance().getChildren(this, type, field, term, pager); } @Override public <P extends ParaObject> List<P> findChildren(String type, String query, Pager... pager) { return CoreUtils.getInstance().findChildren(this, type, query, pager); } @Override public void deleteChildren(String type) { CoreUtils.getInstance().deleteChildren(this, type); } @Override public int hashCode() { int hash = 7; hash = 67 * hash + Objects.hashCode(this.id) + Objects.hashCode(this.name); return hash; } @Override public boolean equals(Object obj) { if (obj == null) { return false; } if (getClass() != obj.getClass()) { return false; } final ParaObject other = (ParaObject) obj; return Objects.equals(this.id, other.getId()); } @Override public String toString() { return ParaObjectUtils.toJSON(this); } /** * Represents HTTP methods allowed to be executed on a specific resource/type. * For example; the 'books' type can have a permission '{ "*" : ["GET"] }' which means * "give read-only permissions to everyone". It is backed by a map of resource names * (object types) to a set of allowed HTTP methods. */ public enum AllowedMethods { /** * Allow unauthenticated requests (guest access). */ GUEST, /** * Deny all requests (no access). */ EMPTY, /** * Restrict access to objects with creatorid matching that of auth user. */ OWN, /** * Allows all HTTP methods (full access). */ READ_WRITE, /** * Allows GET method only. */ GET, /** * Allows POST method only. */ POST, /** * Allows PUT method only. */ PUT, /** * ALlows PATCH method only. */ PATCH, /** * Allows DELETE method only. */ DELETE, /** * Allows read methods: GET, same as {@link #GET}. */ READ_ONLY, /** * Allows write methods: POST, PUT, PATCH and DELETE. */ WRITE_ONLY; /** * All methods allowed. */ public static final EnumSet<AllowedMethods> ALL = EnumSet.of(GET, POST, PUT, PATCH, DELETE); /** * All methods allowed (*). */ public static final EnumSet<AllowedMethods> READ_AND_WRITE = EnumSet.of(READ_WRITE); /** * Only GET is allowed. */ public static final EnumSet<AllowedMethods> READ = EnumSet.of(GET); /** * All methods allowed, except GET. */ public static final EnumSet<AllowedMethods> WRITE = EnumSet.of(POST, PUT, PATCH, DELETE); /** * All methods allowed, except DELETE. */ public static final EnumSet<AllowedMethods> ALL_EXCEPT_DELETE = EnumSet.of(GET, POST, PUT, PATCH); /** * No methods allowed. */ public static final EnumSet<AllowedMethods> NONE = EnumSet.of(EMPTY); /** * Constructs the enum from a string value. * @param value a method name, or ?,w * @return an enum instance */ @JsonCreator public static AllowedMethods fromString(String value) { if (ALLOW_ALL.equals(value)) { return READ_WRITE; } else if ("w".equals(value)) { return WRITE_ONLY; } else if ("?".equals(value)) { return GUEST; } else { try { return valueOf(value.toUpperCase()); } catch (Exception e) { return EMPTY; } } } @Override @JsonValue public String toString() { switch (this) { case READ_WRITE: return ALLOW_ALL; case READ_ONLY: return GET.name(); case GUEST: return "?"; case EMPTY: return "-"; case WRITE_ONLY: return "w"; default: return this.name(); } } } }