RequestManager.java

/*
 * The coLAB project
 * Copyright (C) 2021-2023 AlbaSim, MEI, HEIG-VD, HES-SO
 *
 * Licensed under the MIT License
 */
package ch.colabproject.colab.api.controller;

import ch.colabproject.colab.api.model.common.Tracking;
import ch.colabproject.colab.api.model.user.Account;
import ch.colabproject.colab.api.model.user.HttpSession;
import ch.colabproject.colab.api.model.user.User;
import ch.colabproject.colab.api.persistence.jpa.user.AccountDao;
import ch.colabproject.colab.api.persistence.jpa.user.HttpSessionDao;
import ch.colabproject.colab.api.security.SessionManager;
import ch.colabproject.colab.api.security.permissions.Conditions.Condition;
import ch.colabproject.colab.generator.model.exceptions.HttpErrorMessage;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Callable;
import javax.enterprise.context.RequestScoped;
import javax.inject.Inject;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import javax.ws.rs.container.ContainerRequestContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Request sidekick.
 *
 * @author maxence
 */
@RequestScoped
public class RequestManager {

    /** logger */
    private static final Logger logger = LoggerFactory.getLogger(RequestManager.class);

    /**
     * Access to the persistence unit
     */
    @PersistenceContext(unitName = "COLAB_PU")
    private EntityManager em;

    /**
     * Account persistence handling
     */
    @Inject
    private AccountDao accountDao;

    /**
     * Http session persistence handling
     */
    @Inject
    private HttpSessionDao httpSessionDao;

    /**
     * Session manager
     */
    @Inject
    private SessionManager sessionManager;

    /**
     * id HTTP session associated to current request
     */
    private Long httpSessionId;

    /**
     * Timestamp as return by {@link System#currentTimeMillis() } the request starts at
     */
    private long startTime;

    /**
     * Base url request URL
     */
    private String baseUrl;

    /**
     * Id of the current user account
     */
    private Long currentAccountId;

    /**
     * To store condition which have already been evaluated
     */
    private final Map<Condition, Boolean> conditionCache = new HashMap<>();

    /**
     * Indicates if current user can act as an admin. 0 = no sudo greater than 0 => sudo
     */
    private int sudoAsAdmin = 0;

    /**
     * is the thread run in the so-called security transaction ?
     */
    private boolean inSecurityTx = false;

    /**
     * Is the current transaction already completed ?
     */
    private boolean txDone = false;

    /**
     * In some case, {@link Tracking tracking data} shouldn't be updated. Setting this boolean to
     * prevent allow such behaviour.
     */
    private boolean doNotTrackChange = false;

    /**
     * The HTTP request bound to this request.
     */
    private ContainerRequestContext requestContext;

    /**
     * Get request base url
     *
     * @return url
     */
    public String getBaseUrl() {
        return baseUrl;
    }

    /**
     * Set the request base url
     *
     * @param baseUrl request base url
     */
    public void setBaseUrl(String baseUrl) {
        this.baseUrl = baseUrl;
    }

    /**
     * Get the current httpSession. If subject is not authenticated, null is returned
     *
     * @return the current http session
     */
    public HttpSession getHttpSession() {
        if (this.httpSessionId != null) {
            // make sure to return a managed httpSession
            return httpSessionDao.findHttpSession(this.httpSessionId);
        } else {
            return null;
        }
    }

    /**
     * Get the current HTTPSession or fails with authenticationRequired exception
     *
     * @return the current http session
     *
     * @throws HttpErrorMessage with authenticationRequired if null
     */
    public HttpSession getAndAssertHttpSession() {
        HttpSession httpSession = getHttpSession();
        if (httpSession != null && httpSession.getAccountId() != null) {
            return httpSession;
        } else {
            throw HttpErrorMessage.authenticationRequired();
        }
    }

    /**
     * Attach id of httpSession to this request
     *
     * @param httpSessionId id of the http session
     */
    public void setHttpSessionId(Long httpSessionId) {
        this.httpSessionId = httpSessionId;
        conditionCache.clear();
    }

    /**
     * Get the current authenticated account
     *
     * @return the current account or null if none
     */
    public Account getCurrentAccount() {
        HttpSession httpSession = getHttpSession();
        if (httpSession != null) {
            return httpSession.getAccount();
        } else if (this.currentAccountId != null) {
            return accountDao.findAccount(this.currentAccountId);
        } else {
            return null;
        }
    }

    /**
     * Get the current authenticated user
     *
     * @return the current user or null if none
     */
    public User getCurrentUser() {
        Account account = this.getCurrentAccount();

        if (account != null) {
            return account.getUser();
        } else {
            return null;
        }
    }

    /**
     * set time the current request started
     *
     * @param timestamp start timestamp
     */
    public void setStartTime(long timestamp) {
        this.startTime = timestamp;
    }

    /**
     * Return the time the request started
     *
     * @return start time timestamp in ms
     */
    public long getStartTime() {
        return startTime;
    }

    /**
     * Is the request ran by an authenticated user ?
     *
     * @return true if the current user is fully authenticated
     */
    public Boolean isAuthenticated() {
        return this.getCurrentAccount() != null;
    }

    /**
     * Set the current account.
     *
     * @param account new current account
     */
    public void login(Account account) {
        this.currentAccountId = account.getId();
        if (this.requestContext != null) {
            // only create an http session is the request is a HTTP request
            String userAgent = requestContext.getHeaderString("user-agent");
            HttpSession httpSession = sessionManager.createHttpSession(account, userAgent);
            setHttpSessionId(httpSession.getId());
        }
    }

    /**
     * Clear current account and unsubscribe from all websocket channels.
     */
    public void logout() {
        HttpSession session = this.getHttpSession();
        this.sudo(() -> {
            this.currentAccountId = null;
            if (session != null) {
                sessionManager.deleteHttpSession(session);
            }
            setHttpSessionId(null);
        });
    }

    /**
     * Is current transaction still alive ?
     *
     * @return true if current tx is not dead
     */
    private boolean txExists() {
        return !this.txDone;
    }

    /**
     * Synchronize the persistence context to the underlying database.
     */
    public void flush() {
        if (txExists()) {
            em.flush();
        }
    }

    /**
     * Execute some piece of code with admin privileges.
     *
     * @param action code to execute with admin privileges
     */
    public void sudo(Runnable action) {
        if (txExists()) {
            // make sure to flush to check every pending changes before granting admin rights
            em.flush();
        }

        this.sudoAsAdmin++;
        logger.trace("Sudo #{}", this.sudoAsAdmin);
        action.run();
        if (txExists()) {
            // make sure to flush to apply all pending changes with admin rights
            em.flush();
        }

        logger.trace("EndOfSudo #{}", this.sudoAsAdmin);

        this.sudoAsAdmin--;
    }

    /**
     * Execute some piece of code with admin privileges and return something.
     *
     * @param <R>    return type
     * @param action code to execute with admin privileges
     *
     * @return action result
     *
     * @throws java.lang.Exception if something is thrown during the call
     */
    public <R> R sudo(Callable<R> action) throws Exception {
        if (txExists()) {
            // make sure to flush to check every pending changes before granting admin rights
            em.flush();
        }
        this.sudoAsAdmin++;
        logger.trace("Sudo #{}", this.sudoAsAdmin);
        R result = action.call();
        if (txExists()) {
            // make sure to flush to apply all pending changes with admin rights
            em.flush();
        }
        logger.trace("EndOfSudo #{}", this.sudoAsAdmin);
        this.sudoAsAdmin--;

        return result;
    }

    /**
     * Is the currentUser is an admin or sudo as an admin ?
     *
     * @return true if current user can act as an admin
     */
    public boolean isAdmin() {
        // the sudoAsAdmin is done at first,
        // so we do not need to get the current user if this condition is fulfilled
        if (sudoAsAdmin > 0) {
            return true;
        }

        User currentUser = this.getCurrentUser();
        return currentUser != null && currentUser.isAdmin();
    }

    /**
     * Register condition result
     *
     * @param condition the condition
     * @param result    the result
     */
    public void registerConditionResult(Condition condition, Boolean result) {
        this.conditionCache.put(condition, result);
    }

    /**
     * Get the cached condition result
     *
     * @param condition condition
     *
     * @return true or false if the condition is cached, null if the condition has not been
     *         evaluated yet
     */
    public Boolean getConditionResult(Condition condition) {
        return this.conditionCache.get(condition);
    }

    /**
     * Is the thread run in a transaction dedicated to security condition evaluation
     *
     * @return true/false
     */
    public boolean isInSecurityTx() {
        return inSecurityTx;
    }

    /**
     * Change the inSecurityTx flag
     *
     * @param inSecurityTx new flag
     */
    public void setInSecurityTx(boolean inSecurityTx) {
        this.inSecurityTx = inSecurityTx;
    }

    /**
     * Check if the current transaction is already done
     *
     * @return whether or not the current transaction is already done
     */
    public boolean isTxDone() {
        return txDone;
    }

    /**
     * Mark the current transaction as done or undone
     *
     * @param txDone whether or not the current transaction is already done
     */
    public void setTxDone(boolean txDone) {
        this.txDone = txDone;
    }

    /**
     * Set Do-Not-Track-Change boolean
     *
     * @param value the new value
     */
    public void setDoNotTrackChange(boolean value) {
        this.doNotTrackChange = value;
    }

    /**
     * Get Do-Not-Track-Change value
     *
     * @return should or shouldn't track entity updates?
     */
    public boolean isDoNotTrackChange() {
        return doNotTrackChange;
    }

    /**
     * Set request context
     *
     * @param requestContext the request context
     */
    public void setRequestContext(ContainerRequestContext requestContext) {
        this.requestContext = requestContext;
    }

    /**
     * get the requestContext
     *
     * @return the request context if request has been intitated by a REST call, null otherwise
     */
    public ContainerRequestContext getRequestContext() {
        return this.requestContext;
    }
}