001package com.identityworksllc.iiq.common.access;
002
003import com.identityworksllc.iiq.common.Metered;
004import com.identityworksllc.iiq.common.Utilities;
005import com.identityworksllc.iiq.common.auth.DummyAuthContext;
006import com.identityworksllc.iiq.common.cache.CacheEntry;
007import com.identityworksllc.iiq.common.cache.Caches;
008import com.identityworksllc.iiq.common.cache.VersionedCacheEntry;
009import org.apache.commons.logging.Log;
010import org.apache.commons.logging.LogFactory;
011import sailpoint.api.SailPointContext;
012import sailpoint.api.SailPointFactory;
013import sailpoint.object.AuditEvent;
014import sailpoint.object.Configuration;
015import sailpoint.object.Identity;
016import sailpoint.rest.plugin.BasePluginResource;
017import sailpoint.server.Auditor;
018import sailpoint.tools.GeneralException;
019import sailpoint.tools.Util;
020import sailpoint.web.UserContext;
021
022import javax.servlet.http.HttpServletRequest;
023import java.util.Map;
024import java.util.Objects;
025import java.util.concurrent.ConcurrentHashMap;
026
027import static com.identityworksllc.iiq.common.access.DelegatedAccessConstants.AUDIT_DA_CHECK;
028
029/**
030 * Utilities for delegated access checks. The access checks are implemented as follows:
031 *
032 * - A Configuration exists that contains ThingAccessUtils-friendly access controls.
033 *
034 * - Access controls are nested and cumulative. If a user can't read from an Identity,
035 *   then they trivially can't read any of its attributes.
036 *
037 * - If a 'global' access control exists, its elements will always be added to the controls.
038 *
039 * - A purpose can be specified as a colon-delimited string, e.g., read:private:ssn. Access
040 *   controls will be added from 'read', 'read:private', 'read:private:ssn', if they exist.
041 *   More specific entries will override less specific entries. More specific entries can
042 *   also specify a special '_remove' entry that will suppress upper-level controls.
043 *
044 * If no access controls exist for a purpose string, the answer is always yes.
045 *
046 * The specific purpose strings are arbitrary and are defined in the various IID plugins.
047 * For example, the UI Enhancer has a slew of them, along with its own mechanism for
048 * pointing the access checks at the DA adapter.
049 */
050public class DelegatedAccessController {
051
052    /**
053     * The cache of delegated access objects
054     */
055    private static final ConcurrentHashMap<String, CacheEntry<Map<String, Object>>> cache = new ConcurrentHashMap<>();
056
057    /**
058     * The IIQ context
059     */
060    private final SailPointContext context;
061
062    /**
063     * The logger
064     */
065    private final Log log;
066
067    /**
068     * The requester context, usually a plugin API call
069     */
070    private final UserContext requesterContext;
071
072    /**
073     * Constructs a new delegated access controller with the given IIQ and user context
074     * @param context The IIQ context
075     * @param requester The subject or requester Identity
076     */
077    public DelegatedAccessController(SailPointContext context, Identity requester) {
078        this(new DummyAuthContext(context, requester.getName()));
079    }
080
081    /**
082     * Constructor for delegated access
083     * @param requesterContext A plugin resource (likely 'this' in your plugin)
084     */
085    public DelegatedAccessController(UserContext requesterContext) {
086        this.context = requesterContext.getContext();
087        this.log = LogFactory.getLog(DelegatedAccessController.class);
088
089        // NOTE: You can use ThingAccessUtils.createFakePluginContext() to create one of these
090        this.requesterContext = Objects.requireNonNull(requesterContext);
091    }
092
093
094    /**
095     * Clear cache method, for use via the UI Toolbox cache rule
096     */
097    public static void clearCache() {
098        cache.clear();
099
100        Caches.getConfigurationCache().clear();
101    }
102
103    /**
104     * Returns true if an explicit control exists for the given purpose token. In
105     * other words, if the token is a:b:c, this method returns true only if a:b:c
106     * exists in the configuration. A subset, such as a:b, will not match.
107     *
108     * @param context The IIQ context
109     * @param purpose The colon-delimited purpose
110     * @return True of an explicit control (i.e., does not match as a substring) exists for the given purpose
111     * @throws GeneralException if anything fails
112     */
113    public static boolean explicitControlExists(SailPointContext context, String purpose) throws GeneralException {
114        Configuration delegatedAccessConfig = getDelegatedAccessConfig(context);
115
116        return delegatedAccessConfig.containsAttribute(purpose);
117    }
118
119    /**
120     * Gets the cached controls for the given purpose or calculates a new one.
121     * This can be a somewhat expensive operation, so caching is critical. Caches
122     * time out after 60 seconds, or the value of configuration key
123     * `IIQCommon.DelegatedAccessController.CacheTimeoutMillis`.
124     *
125     * @param context The SailPointContext to use the load the object
126     * @param purpose The name of the object to retrieve
127     * @return The object retrieved
128     * @throws IllegalArgumentException if there is a problem loading the object
129     */
130    private static Map<String, Object> getAssembledControls(SailPointContext context, String purpose) throws IllegalArgumentException {
131        return cache.compute(purpose, (key, value) -> {
132            // This is the existing entry in the cache
133            if (value == null || value.isExpired()) {
134                int timeout = Configuration.getSystemConfig().getInt(DelegatedAccessConstants.CONFIG_DA_CACHE_TIMEOUT);
135                if (timeout < 1) {
136                    timeout = 60000;
137                }
138
139                try {
140                    return new VersionedCacheEntry<>(new DelegatedAccessAssembler(context).assembleControls(purpose), System.currentTimeMillis() + timeout);
141                } catch (GeneralException e) {
142                    throw new IllegalArgumentException(e);
143                }
144            } else {
145                return value;
146            }
147        }).getValue();
148    }
149
150    /**
151     * Gets the delegated access config object if one exists
152     * @param context The IIQ context
153     * @return The Configuration if one exists
154     * @throws GeneralException if the configuration does not exist
155     */
156    public static Configuration getDelegatedAccessConfig(SailPointContext context) throws GeneralException {
157        SailPointContext previousContext = SailPointFactory.getCurrentContext();
158        try {
159            SailPointFactory.setContext(context);
160
161            String configName = Configuration.getSystemConfig().getString(DelegatedAccessConstants.CONFIG_DELEGATED_ACCESS);
162            if (Util.isNullOrEmpty(configName)) {
163                throw new GeneralException("The system configuration must contain a property 'configDelegatedAccess'");
164            }
165
166            Configuration delegatedAccessConfig = Caches.getConfiguration(configName);
167            if (delegatedAccessConfig == null) {
168                throw new GeneralException("The delegated access configuration '" + configName + "' does not exist");
169            }
170            return delegatedAccessConfig;
171        } finally {
172            SailPointFactory.setContext(previousContext);
173        }
174    }
175
176    /**
177     * Gets the remote IP address of the user from the given HttpServletRequest. This can
178     * be used in a situation where there is no FacesContext, like in a web service call.
179     *
180     * @param request The request to grab the IP from
181     * @return The remote IP of the user
182     */
183    public static String getRemoteIp(HttpServletRequest request) {
184        String remoteAddr = null;
185        if (request != null) {
186            remoteAddr = request.getHeader("X-FORWARDED-FOR");
187            if (remoteAddr == null || remoteAddr.isEmpty()) {
188                remoteAddr = request.getRemoteAddr();
189            }
190        }
191        return remoteAddr;
192    }
193
194    /**
195     * Returns true if, according to the configuration, the logged in user can do the given
196     * action (purpose) against the target user. If the concept is generic, like "can load
197     * plugin page", you can pass null as the target Identity.
198     *
199     * The attempt will not be audited.
200     *
201     * @param target The target user
202     * @param purpose The purpose for which we are checking access (e.g., read, edit, etc)
203     * @return true if access is allowed
204     * @throws GeneralException on any check failures
205     */
206    public boolean canSeeIdentity(Identity target, String purpose) throws GeneralException {
207        return canSeeIdentity(target, purpose, false);
208    }
209
210    /**
211     * Returns true if, according to the configuration, the logged in user can do the given
212     * action (purpose) against the target user. If the concept is generic, like "can load
213     * plugin page", you can pass null as the target Identity.
214     *
215     * @param target The target user
216     * @param purpose The purpose for which we are checking access (e.g., read, edit, etc)
217     * @param audit True if we should audit this access attempt
218     * @return true if access is allowed
219     * @throws GeneralException on any check failures
220     */
221    public boolean canSeeIdentity(Identity target, String purpose, boolean audit) throws GeneralException {
222        if (log.isDebugEnabled()) {
223            log.debug("START: Access check for " + target + ", purpose = " + purpose);
224        }
225
226        boolean result = Metered.meter("DelegatedAccessController.canSeeIdentity.check." + purpose, () -> canSeeIdentityImpl(target, purpose));
227
228        if (log.isDebugEnabled()) {
229            log.debug("FINISH: Access check for " + target + ", purpose = " + purpose + ", allowed = " + result);
230        }
231
232        if (audit) {
233            Metered.meter("DelegatedAccessController.canSeeIdentity.audit", () -> {
234                AuditEvent auditEvent = new AuditEvent();
235                auditEvent.setSource(requesterContext.getLoggedInUser().getId());
236                auditEvent.setTarget(target.getId());
237                auditEvent.setAction(AUDIT_DA_CHECK);
238                auditEvent.setServerHost(Util.getHostName());
239                if (requesterContext instanceof BasePluginResource) {
240                    auditEvent.setClientHost(getRemoteIp(((BasePluginResource) requesterContext).getRequest()));
241                }
242                auditEvent.setString1(purpose);
243                auditEvent.setString2(String.valueOf(result));
244
245                Utilities.withPrivateContext((context) -> {
246                    Auditor.log(auditEvent);
247                });
248            });
249        }
250
251        return result;
252    }
253
254    /**
255     * The internal implementation of canSeeIdentity so that it can be easily metered
256     * @param target The target identity
257     * @param purpose The purpose for which we are checking access
258     * @return True if access is allowed
259     * @throws GeneralException on any check failure
260     */
261    private boolean canSeeIdentityImpl(Identity target, String purpose) throws GeneralException {
262        Map<String, Object> controls = getAssembledControls(context, purpose);
263
264        boolean result;
265
266        if (controls.isEmpty()) {
267            // TODO do we want to default to least-privilege and add privileges?
268            result = true;
269        } else {
270            AccessCheckInput input = new AccessCheckInput();
271            input.setTarget(target);
272            input.setThingName(purpose);
273            input.setConfiguration(controls);
274            input.setUserContext(this.requesterContext);
275
276            AccessCheckResponse response = AccessCheck.accessCheck(input);
277            result = response.isAllowed();
278
279            if (log.isDebugEnabled()) {
280                log.debug("Access check response: " + response);
281            }
282        }
283
284        return result;
285    }
286
287}