Authorization.php

PHP

Path: src/Authorizer/Authorization.php

<?php

namespace mini\Authorizer;

use mini\Hooks\Handler;

/**
 * Authorization service
 *
 * Manages ability registration and authorization queries via Handler dispatch.
 * Handlers are registered per-class and resolved by type specificity.
 *
 * ## Execution Order
 *
 * For `can(Ability::Delete, $post)` where Post extends Model implements TenantScoped:
 *
 * 1. **Guards** (deny-only, type-specific):
 *    Post guards → TenantScoped guards → Model guards
 *    If any guard returns false → deny immediately
 *
 * 2. **Handlers** (allow/deny, type-specific):
 *    Post → TenantScoped → Model → fallback
 *
 * 3. **Default**: allow (if no handler responds)
 *
 * ## Guards (Cross-Cutting Security)
 *
 * ```php
 * // Guards run FIRST and can only deny or pass
 * $auth->guard(TenantScoped::class)->listen(function(AuthorizationQuery $q): ?bool {
 *     $entity = $q->instance();
 *     if ($entity && $entity->tenant_id !== auth()->getClaim('tenant_id')) {
 *         return false;  // Deny - wrong tenant
 *     }
 *     return null;  // Pass - continue checking
 * });
 * ```
 *
 * ## Handlers
 *
 * ```php
 * // Handlers run after guards pass
 * $auth->for(User::class)->listen(function(AuthorizationQuery $q): ?bool {
 *     return match ($q->ability) {
 *         Ability::List => auth()->isAuthenticated(),
 *         Ability::Create => auth()->hasRole('admin'),
 *         Ability::Read => true,
 *         Ability::Update, Ability::Delete =>
 *             $q->instance()?->id === auth()->getUserId() || auth()->hasRole('admin'),
 *         default => null,
 *     };
 * });
 * ```
 */
class Authorization
{
    /** @var array<string, Handler<AuthorizationQuery, bool>> */
    private array $guards = [];

    /** @var array<string, Handler<AuthorizationQuery, bool>> */
    private array $handlers = [];

    /** @var Handler<AuthorizationQuery, bool> Fallback for unmatched classes */
    public Handler $fallback;

    /** @var array<string, true> */
    private array $customAbilities = [];

    public function __construct()
    {
        $this->fallback = new Handler('authorization:fallback');
    }

    /**
     * Get or create guard for a specific resource
     *
     * Guards run BEFORE normal handlers and can only deny (return false) or pass (return null).
     * Use guards for cross-cutting security concerns like tenant isolation.
     *
     * Guards follow the same type specificity as handlers but run in a separate phase:
     * 1. All guards are checked first (can deny)
     * 2. Then normal handlers are checked (can allow or deny)
     *
     * @param string $resource Class name or resource identifier
     * @return Handler<AuthorizationQuery, bool>
     */
    public function guard(string $resource): Handler
    {
        return $this->guards[$resource] ??= new Handler("authorization-guard:$resource");
    }

    /**
     * Get or create handler for a specific resource
     *
     * Resources can be class names or arbitrary identifiers (e.g., 'virtualdatabase.countries').
     * For class names, handlers registered for more specific classes are checked before
     * handlers for parent classes or interfaces.
     *
     * @param string $resource Class name or resource identifier
     * @return Handler<AuthorizationQuery, bool>
     */
    public function for(string $resource): Handler
    {
        return $this->handlers[$resource] ??= new Handler("authorization:$resource");
    }

    /**
     * Check if the current user can perform an ability on an entity
     *
     * Execution order:
     * 1. Guards (deny-only, type-specific) - if any returns false, deny immediately
     * 2. Handlers (allow/deny, type-specific)
     * 3. Fallback handler
     * 4. Default: allow
     *
     * @param Ability|string $ability The ability to check
     * @param object|string $entity Entity instance or class name
     * @param string|null $field Optional field name for field-level checks
     * @return bool True if allowed, false if denied
     * @throws \InvalidArgumentException If string ability is not registered
     */
    public function can(Ability|string $ability, object|string $entity, ?string $field = null): bool
    {
        if (is_string($ability) && !isset($this->customAbilities[$ability])) {
            throw new \InvalidArgumentException(
                "Unknown ability '$ability'. Register with registerAbility() first."
            );
        }

        $query = new AuthorizationQuery($ability, $entity, $field);
        $class = is_string($entity) ? $entity : $entity::class;

        // Phase 1: Guards (deny-only)
        foreach ($this->walkClassHierarchy($class) as $type) {
            if (isset($this->guards[$type])) {
                $result = $this->guards[$type]->trigger($query);
                if ($result === true) {
                    throw new \LogicException(
                        "Guard for '$type' returned true. Guards can only deny (false) or pass (null), not allow."
                    );
                }
                if ($result === false) {
                    return false; // Guard denied
                }
            }
        }

        // Phase 2: Normal handlers
        foreach ($this->walkClassHierarchy($class) as $type) {
            if (isset($this->handlers[$type])) {
                $result = $this->handlers[$type]->trigger($query);
                if ($result !== null) {
                    return $result;
                }
            }
        }

        // Try fallback
        $result = $this->fallback->trigger($query);
        return $result ?? true;
    }

    /**
     * Register a custom string ability
     *
     * Standard Ability enum values are always available. Custom string abilities
     * must be registered before use to ensure typos are caught early.
     *
     * @param string $abilityName Custom ability name
     */
    public function registerAbility(string $abilityName): void
    {
        $this->customAbilities[$abilityName] = true;
    }

    /**
     * Walk class hierarchy in specificity order
     *
     * For objects: yields class, then direct interfaces, then parent class,
     * then parent's direct interfaces, etc.
     *
     * For class strings that don't exist: yields just the string itself.
     *
     * @param string $class Class name to walk
     * @return \Generator<string>
     */
    private function walkClassHierarchy(string $class): \Generator
    {
        if (!class_exists($class) && !interface_exists($class)) {
            yield $class;
            return;
        }

        $rc = new \ReflectionClass($class);
        while ($rc !== false) {
            yield $rc->getName();

            // Direct interfaces (not inherited from parent)
            $parent = $rc->getParentClass();
            foreach ($rc->getInterfaceNames() as $interface) {
                if ($parent === false || !$parent->implementsInterface($interface)) {
                    yield $interface;
                }
            }

            $rc = $parent;
        }
    }
}