Memoized attribute lookups per class::method. */ private array $cache = []; /** @var array Memoized "is this param mandatory" lookups per class::method::param. */ private array $mandatoryParamCache = []; public function __construct(private PermissionService $permissions) {} /** * Enforce the permission required by $class::$method, if any. * * @param object|class-string $class The controller instance or service class name. * @param array $params Request/method parameters (for project-id resolution). * * @throws AuthorizationException When denied and not in audit mode. */ public function enforce(object|string $class, string $method, array $params = []): void { $attribute = $this->attributeFor($class, $method); if ($attribute === null) { return; } // Entity-scoped: the method loads the entity and authorizes its project in its own // body (the enforcer can't see the entity's project here). The attribute is just the // declared-coverage marker; defer to the in-method $this->authorize() call. if ($attribute->entityScoped) { return; } $reason = ''; if ($attribute->global) { $allowed = $this->permissions->currentUserCan($attribute->permission, null, true); } else { $projectId = $this->resolveProjectId($attribute, $class, $method, $params); if ($projectId === false) { // A declared projectIdParam that can't be resolved to a concrete project, on a // method whose signature makes that param mandatory, fails closed: we cannot // identify which project to authorize against, and silently falling back to the // session project would authorize the wrong one. Optional params keep the // session fallback (see resolveProjectId). $allowed = false; $reason = sprintf(' (unresolved mandatory project param "%s")', $attribute->projectIdParam); } else { $allowed = $this->permissions->currentUserCan($attribute->permission, $projectId); } } if ($allowed) { return; } $target = (is_object($class) ? $class::class : $class).'::'.$method; $user = session('userdata.id') ?? 'guest'; if (! $this->shouldBlock()) { Log::info(sprintf('[permissions:audit] would deny "%s" on %s for user %s%s', $attribute->permission, $target, $user, $reason)); return; } // Log the permission key server-side for audit; the thrown exception stays generic // so the authorization vocabulary is never exposed to the client. Log::info(sprintf('Authorization denied: "%s" on %s for user %s%s', $attribute->permission, $target, $user, $reason)); throw new AuthorizationException; } /** * The RequiresPermission attribute on $class::$method, or null. Memoized; tolerant of * missing methods (returns null) so it can guard any dispatch target. */ private function attributeFor(object|string $class, string $method): ?RequiresPermission { $className = is_object($class) ? $class::class : $class; $key = $className.'::'.$method; if (array_key_exists($key, $this->cache)) { return $this->cache[$key]; } $attribute = null; try { $attributes = (new ReflectionMethod($className, $method))->getAttributes(RequiresPermission::class); if ($attributes !== []) { $attribute = $attributes[0]->newInstance(); } } catch (ReflectionException) { $attribute = null; } return $this->cache[$key] = $attribute; } /** * Resolve the project id for a project-scoped check. * * Three outcomes: * - int — a concrete project to authorize against (the declared param, or the session * project for attributes that declare no param); * - null — no concrete project and none required (the session project was empty on an * attribute that allows the fallback): the engine checks capability only; * - false — DENY. The attribute declares a projectIdParam, the value is absent/null/zero, * AND the target method's signature makes that param mandatory (no default). * We can't identify the project and the method can't run without it, so we fail * closed instead of authorizing against the unrelated session project. * * @param array $params */ private function resolveProjectId(RequiresPermission $attribute, object|string $class, string $method, array $params): int|false|null { // No declared param: scope to the ambient session project (session-scoped views). if ($attribute->projectIdParam === null) { return $this->sessionProject(); } $name = $attribute->projectIdParam; // Declared param present and a real positive integer: scope to it. Anything else — a // missing/null value, a non-numeric or non-positive string, or a non-scalar like the // array from `projectId[]=7` (which a bare `(int)` cast would silently turn into 1) — is // treated as unresolved and falls through to the mandatory check below. $resolved = $this->positiveInt($params[$name] ?? null); if ($resolved !== null) { return $resolved; } // Declared but unresolved. Fail closed only when the method proves the project is // mandatory; methods that default the project (e.g. poll/dashboard "current project" // endpoints) legitimately mean "the session project" and keep the fallback. if ($this->paramIsMandatory($class, $method, $name)) { return false; } return $this->sessionProject(); } /** * Coerce a request value to a positive project id, or null if it doesn't represent one. * Strict on purpose — this gates authorization, so anything that isn't an in-range positive * integer is rejected rather than cast. */ private function positiveInt(mixed $value): ?int { // Only an int or a string can name a project id — reject arrays/floats/bools/null outright. if (! is_int($value) && ! is_string($value)) { return null; } // FILTER_VALIDATE_INT rejects non-numeric strings AND out-of-range values; a bare (int) // cast would instead saturate a giant all-digits string to PHP_INT_MAX and treat it as a // real (wrong) project id. $int = filter_var($value, FILTER_VALIDATE_INT); return ($int !== false && $int > 0) ? $int : null; } /** The current session project as an int, or null when none/zero is set. */ private function sessionProject(): ?int { $current = session('currentProject'); return ($current === null || (int) $current === 0) ? null : (int) $current; } /** * Whether $paramName on $class::$method is mandatory — i.e. has no default value, so a * caller cannot legitimately omit it. Mirrors the JSON-RPC dispatcher's own "required" * definition ({@see \Leantime\Domain\Api\Controllers\Jsonrpc::prepareParameters()}: * `! isDefaultValueAvailable()`) so the two stay in lockstep. Memoized; tolerant of a * missing method/param (returns false → keep the session fallback) so it never over-denies * a target it can't reflect (e.g. a controller action taking a single $params array). */ private function paramIsMandatory(object|string $class, string $method, string $paramName): bool { $className = is_object($class) ? $class::class : $class; $key = $className.'::'.$method.'::'.$paramName; if (array_key_exists($key, $this->mandatoryParamCache)) { return $this->mandatoryParamCache[$key]; } $mandatory = false; try { foreach ((new ReflectionMethod($className, $method))->getParameters() as $param) { if ($param->getName() === $paramName) { $mandatory = ! $param->isDefaultValueAvailable(); break; } } } catch (ReflectionException) { $mandatory = false; } return $this->mandatoryParamCache[$key] = $mandatory; } /** Whether denials block (true) or are only logged (false, the default — audit mode). */ private function shouldBlock(): bool { try { return (bool) config('permissions.enforce', false); } catch (\Throwable) { return false; } } }