<?php

/**
 * @file classes/security/authorization/AuthorizationDecisionManager.php
 *
 * Copyright (c) 2014-2021 Simon Fraser University
 * Copyright (c) 2000-2021 John Willinsky
 * Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
 *
 * @class AuthorizationDecisionManager
 *
 * @ingroup security_authorization
 *
 * @brief A class that can take a list of authorization policies, apply
 *  them to the current authorization request context and return an
 *  authorization decision.
 *
 *  This decision manager implements the following logic to combine
 *  authorization policies:
 *  - If any of the given policies applies with a result of
 *    AUTHORIZATION_DENY then the decision manager will deny access
 *    (=deny overrides policy).
 *  - If none of the given policies applies then the decision
 *    manager will deny access (=whitelist approach, deny if none
 *    applicable).
 */

namespace PKP\security\authorization;

class AuthorizationDecisionManager
{
    public const AUTHORIZATION_NOT_APPLICABLE = 3;

    /** @var PolicySet the root policy set */
    public $_rootPolicySet;

    /** @var array */
    public $_authorizationMessages = [];

    /** @var array authorized objects provided by authorization policies */
    public $_authorizedContext = [];

    /**
     * Constructor
     */
    public function __construct()
    {
        // Instantiate the main policy set we'll add root policies to.
        $this->_rootPolicySet = new PolicySet(PolicySet::COMBINING_DENY_OVERRIDES);
    }


    //
    // Setters and Getters
    //
    /**
     * Set the default decision if none of the
     * policies in the root policy set applies.
     *
     * @param int $decisionIfNoPolicyApplies
     */
    public function setDecisionIfNoPolicyApplies($decisionIfNoPolicyApplies)
    {
        $this->_rootPolicySet->setEffectIfNoPolicyApplies($decisionIfNoPolicyApplies);
    }

    /**
     * Add an authorization policy or a policy set.
     *
     * @param AuthorizationPolicy|PolicySet $policyOrPolicySet
     * @param bool $addToTop whether to insert the new policy
     *  to the top of the list.
     */
    public function addPolicy($policyOrPolicySet, $addToTop = false)
    {
        $this->_rootPolicySet->addPolicy($policyOrPolicySet, $addToTop);
    }

    /**
     * Add an authorization message
     *
     * @param string $message
     */
    public function addAuthorizationMessage($message)
    {
        $this->_authorizationMessages[] = $message;
    }

    /**
     * Return all authorization messages
     *
     * @return array
     */
    public function getAuthorizationMessages()
    {
        return $this->_authorizationMessages;
    }

    /**
     * Retrieve an object from the authorized context
     *
     * @param int $assocType
     *
     * @return mixed will return null if the context
     *  for the given assoc type does not exist.
     */
    public function &getAuthorizedContextObject($assocType)
    {
        if (isset($this->_authorizedContext[$assocType])) {
            return $this->_authorizedContext[$assocType];
        } else {
            $nullVar = null;
            return $nullVar;
        }
    }

    /**
     * Get the authorized context.
     *
     * @return array
     */
    public function &getAuthorizedContext()
    {
        return $this->_authorizedContext;
    }


    //
    // Public methods
    //
    /**
     * Take an authorization decision.
     *
     * @return int one of AUTHORIZATION_PERMIT or
     *  AUTHORIZATION_DENY.
     */
    public function decide()
    {
        // Decide the root policy set which will recursively decide
        // all nested policy sets and return a single decision.
        $callOnDeny = null;
        $decision = $this->_decidePolicySet($this->_rootPolicySet, $callOnDeny);
        assert($decision !== self::AUTHORIZATION_NOT_APPLICABLE);

        // Call the "call on deny" advice
        if ($decision === AuthorizationPolicy::AUTHORIZATION_DENY && !is_null($callOnDeny)) {
            if (is_array($callOnDeny)) {
                [$classOrObject, $method, $parameters] = $callOnDeny;
                $methodCall = [$classOrObject, $method];
                if (!is_callable($methodCall)) {
                    throw new \Exception('Call-on-deny advice is not a callable by array!');
                }
                call_user_func_array($methodCall, $parameters);
            } else { // Callable directly specified (e.g. closure)
                if (!is_callable($callOnDeny)) {
                    throw new \Exception('Call-on-deny advice is not a callable!');
                }
                call_user_func($callOnDeny);
            }
        }

        return $decision;
    }


    //
    // Private helper methods
    //
    /**
     * Recursively decide the given policy set.
     *
     * @param PolicySet $policySet
     * @param int $callOnDeny A "call-on-deny" advice will be passed
     *  back by reference if found.
     *
     * @return int one of the AUTHORIZATION_* values.
     */
    public function _decidePolicySet(&$policySet, &$callOnDeny)
    {
        // Configure the decision algorithm.
        $combiningAlgorithm = $policySet->getCombiningAlgorithm();
        switch ($combiningAlgorithm) {
            case PolicySet::COMBINING_DENY_OVERRIDES:
                $dominantEffect = AuthorizationPolicy::AUTHORIZATION_DENY;
                $overriddenEffect = AuthorizationPolicy::AUTHORIZATION_PERMIT;
                break;

            case PolicySet::COMBINING_PERMIT_OVERRIDES:
                $dominantEffect = AuthorizationPolicy::AUTHORIZATION_PERMIT;
                $overriddenEffect = AuthorizationPolicy::AUTHORIZATION_DENY;
                break;

            default:
                assert(false);
        }

        // Set the default decision.
        $decision = $policySet->getEffectIfNoPolicyApplies();

        // The following flag will record when the
        // overridden decision state is returned by
        // at least one policy.
        $decidedByOverriddenEffect = false;

        // Separated from below for bug #6821.
        $context = & $this->getAuthorizedContext();

        // Go through all policies within the policy set
        // and combine them with the configured algorithm.
        foreach ($policySet->getPolicies() as $policy) {
            // Treat policies and policy sets differently.
            switch (true) {
                case $policy instanceof AuthorizationPolicy:
                    // Make sure that the policy can access the latest authorized context.
                    // NB: The authorized context is set by reference. This means that it
                    // will change globally if changed by the policy which is intended
                    // behavior so that policies can access authorized objects provided
                    // by policies called earlier in the authorization process.
                    $policy->setAuthorizedContext($context);

                    // Check whether the policy applies.
                    if ($policy->applies()) {
                        // If the policy applies then retrieve its effect.
                        $effect = $policy->effect();
                    } else {
                        $effect = self::AUTHORIZATION_NOT_APPLICABLE;
                    }
                    break;

                case $policy instanceof PolicySet:
                    // We found a nested policy set.
                    $effect = $this->_decidePolicySet($policy, $callOnDeny);
                    break;

                default:
                    assert(false);
            }

            // Try the next policy if this policy didn't apply.
            if ($effect === self::AUTHORIZATION_NOT_APPLICABLE) {
                continue;
            }
            assert($effect === AuthorizationPolicy::AUTHORIZATION_PERMIT || $effect === AuthorizationPolicy::AUTHORIZATION_DENY);

            // "Deny" decision may cause a message to the end user.
            if ($policy instanceof AuthorizationPolicy && $effect == AuthorizationPolicy::AUTHORIZATION_DENY
                    && $policy->hasAdvice(AuthorizationPolicy::AUTHORIZATION_ADVICE_DENY_MESSAGE)) {
                $this->addAuthorizationMessage($policy->getAdvice(AuthorizationPolicy::AUTHORIZATION_ADVICE_DENY_MESSAGE));
            }

            // Process the effect.
            if ($effect === $overriddenEffect) {
                $decidedByOverriddenEffect = true;
            } else {
                // In case of a "deny overrides" we allow a "call-on-deny" advice.
                if ($policy instanceof AuthorizationPolicy && $dominantEffect == AuthorizationPolicy::AUTHORIZATION_DENY
                        && $policy->hasAdvice(AuthorizationPolicy::AUTHORIZATION_ADVICE_CALL_ON_DENY)) {
                    $callOnDeny = $policy->getAdvice(AuthorizationPolicy::AUTHORIZATION_ADVICE_CALL_ON_DENY);
                }

                // Only one dominant effect overrides all other effects
                // so we don't even have to evaluate other policies.
                return $dominantEffect;
            }
        }

        // Only return an overridden effect if at least one
        // policy returned that effect and none returned the
        // dominant effect.
        if ($decidedByOverriddenEffect) {
            $decision = $overriddenEffect;
        }
        return $decision;
    }
}

if (!PKP_STRICT_MODE) {
    class_alias('\PKP\security\authorization\AuthorizationDecisionManager', '\AuthorizationDecisionManager');
    define('AUTHORIZATION_NOT_APPLICABLE', AuthorizationDecisionManager::AUTHORIZATION_NOT_APPLICABLE);
}
