OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View File

@@ -0,0 +1,133 @@
<?php
namespace Leantime\Core\Auth;
use Illuminate\Auth\Access\Gate;
use Illuminate\Auth\Middleware\RequirePassword;
use Illuminate\Contracts\Auth\Access\Gate as GateContract;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Routing\ResponseFactory;
use Illuminate\Contracts\Routing\UrlGenerator;
use Illuminate\Support\ServiceProvider;
use Leantime\Domain\Auth\Guards\ApiGuard;
use Leantime\Domain\Auth\Guards\WebGuard;
use Leantime\Domain\Auth\Services\Auth as AuthService;
use Leantime\Domain\Auth\Services\AuthUser;
use Leantime\Domain\Oidc\Services\Oidc as OidcService;
class AuthenticationServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*
* @return void
*/
public function register()
{
// $this->app->singleton(AuthService::class, AuthService::class);
// $this->app->singleton(OidcService::class, OidcService::class);
$this->registerAuthenticator();
$this->registerUserResolver();
$this->registerAccessGate();
$this->registerRequirePassword();
$this->registerRequestRebindHandler();
$this->registerEventRebindHandler();
}
protected function registerAuthenticator()
{
$this->app->singleton('auth', function ($app) {
return new \Illuminate\Auth\AuthManager($app);
});
$this->app->singleton('auth.driver', fn ($app) => $app['auth']->guard());
}
protected function registerUserResolver()
{
$this->app->bind(AuthenticatableContract::class, fn ($app) => call_user_func($app['auth']->userResolver()));
}
protected function registerAccessGate()
{
$this->app->singleton(GateContract::class, function ($app) {
return new Gate($app, fn () => call_user_func($app['auth']->userResolver()));
});
}
/**
* Register a resolver for the authenticated user.
*
* @return void
*/
protected function registerRequirePassword()
{
$this->app->bind(RequirePassword::class, function ($app) {
return new RequirePassword(
$app[ResponseFactory::class],
$app[UrlGenerator::class],
$app['config']->get('auth.password_timeout')
);
});
}
/**
* Handle the re-binding of the request binding.
*
* @return void
*/
protected function registerRequestRebindHandler()
{
$this->app->rebinding('request', function ($app, $request) {
$request->setUserResolver(function ($guard = null) use ($app) {
return call_user_func($app['auth']->userResolver(), $guard);
});
});
}
/**
* Handle the re-binding of the event dispatcher binding.
*
* @return void
*/
protected function registerEventRebindHandler()
{
$this->app->rebinding('events', function ($app, $dispatcher) {
if (! $app->resolved('auth') ||
$app['auth']->hasResolvedGuards() === false) {
return;
}
if (method_exists($guard = $app['auth']->guard(), 'setDispatcher')) {
$guard->setDispatcher($dispatcher);
}
});
}
public function boot()
{
$this->app['auth']->provider('leantimeUsers', function ($app, array $config) {
return new AuthUser(
$app->make(\Leantime\Domain\Auth\Services\Auth::class)
);
});
$this->app['auth']->extend('leantime', function ($app, $name, array $config) {
return new WebGuard(
$app['auth']->createUserProvider($config['provider']),
$app->make(\Leantime\Domain\Auth\Services\Auth::class)
);
});
$this->app['auth']->extend('jsonRpc', function ($app, $name, array $config) {
return new ApiGuard(
$app['auth']->createUserProvider($config['provider']),
$app->make(\Leantime\Domain\Api\Services\Api::class),
$app['request']
);
});
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Leantime\Core\Auth\Contracts;
/**
* Narrow contract the permission engine depends on for project-level data access and the
* per-project role, implemented by the Projects domain service.
*
* The engine lives in Core and must not depend on the 2,900-line Projects god-service
* directly; it depends on this abstraction (bound to Projects in PermissionServiceProvider),
* which also keeps the surface small and sidesteps circular-reference risk.
*/
interface ChecksProjectAccess
{
/**
* Whether the user can access the given project (assigned, or via the project's
* `psettings` — admin/owner bypass handled by the implementation/engine).
*/
public function isUserAssignedToProject(int $userId, int $projectId): bool;
/**
* The user's explicit role within the project, or '' when none is set (inherits global).
* Returns the stored role key as a string.
*/
public function getProjectRole(int $userId, int $projectId): string;
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Leantime\Core\Auth\Permissions;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Enforces #[RequiresPermission] for native Laravel-routed controllers (Blueprints, the
* relocated image/upload controllers, etc.) — the controllers that do NOT go through
* Frontcontroller. Applied to all domain/plugin routes by {@see \Leantime\Core\Routing\RouteLoader}.
*
* Reads the attribute off the matched route's controller@method via the shared
* {@see PermissionEnforcer} (injected, not resolved through the app() helper), so the
* attribute remains the single source of truth — no per-route `can:` duplication. A method
* without the attribute is a no-op.
*/
class CheckPermissions
{
public function __construct(private PermissionEnforcer $enforcer) {}
public function handle(Request $request, Closure $next): Response
{
$route = $request->route();
if ($route instanceof \Illuminate\Routing\Route) {
$controller = $route->getControllerClass();
$method = $route->getActionMethod();
// Skip closure routes (no controller) and invokable/closure actions where the
// "method" resolves to the class name itself.
if (is_string($controller) && $controller !== '' && is_string($method) && $method !== $controller) {
$this->enforcer->enforce($controller, $method, $request->all());
}
}
return $next($request);
}
}

View File

@@ -0,0 +1,187 @@
<?php
namespace Leantime\Core\Auth\Permissions;
use Illuminate\Support\Str;
/**
* The single, central definition of the six built-in roles and the permissions they hold
* by default — the permission-era equivalent of a Spatie roles-and-permissions seeder.
*
* This is the ONLY place built-in role→permission assignment lives. Domains declare verbs
* ({@see ProvidesPermissions}); this maps those verbs onto roles. After install the
* `zp_role_permissions` table is the runtime source of truth and the admin UI edits it —
* this class only provides the initial defaults.
*
* Defaults are expressed as incremental grant rules per role and **unioned up the
* hierarchy** (a role inherits every lower role's grants), so the rules read as deltas:
* - readonly : view project content
* - commenter : + comment / upload
* - editor : + create / edit / delete
* - manager : + everything else on project content (e.g. project settings)
* - admin : + all company-wide capabilities, except company settings
* - owner : + everything (incl. company settings)
*
* A rule matches a {@see Permission} by scope (project- vs company-scoped), by verb (the
* last dotted segment, or `*` for all), minus any excluded keys/prefixes.
*/
final class DefaultRolePermissions
{
/**
* Built-in roles, ordered low→high. `level` preserves the legacy hierarchy weight.
*
* @var array<int, array{name: string, displayName: string, level: int}>
*/
private const ROLES = [
['name' => 'readonly', 'displayName' => 'Read Only', 'level' => 5],
['name' => 'commenter', 'displayName' => 'Commenter', 'level' => 10],
['name' => 'editor', 'displayName' => 'Editor', 'level' => 20],
['name' => 'manager', 'displayName' => 'Company Manager', 'level' => 30],
['name' => 'admin', 'displayName' => 'Admin', 'level' => 40],
['name' => 'owner', 'displayName' => 'Owner', 'level' => 50],
];
/**
* Incremental default grants per role (unioned up the hierarchy by {@see grantsFor()}).
*
* Each rule: scope = project|global|any; verbs = list of last-segment verbs or ['*'];
* exclude = exact keys or 'prefix.*' globs removed from the match.
*
* A rule grants by `verbs` (the convention) OR by explicit `keys` (for permissions that
* don't follow the verb convention).
*
* @var array<string, array<int, array{scope: string, verbs?: array<int, string>, keys?: array<int, string>, exclude?: array<int, string>}>>
*/
private const GRANTS = [
'readonly' => [['scope' => 'project', 'verbs' => ['view']]],
'commenter' => [
['scope' => 'project', 'verbs' => ['comment', 'upload']],
// Commenting via the Comments domain: the 'create' verb otherwise seeds at
// editor+, but a commenter is allowed to add comments — grant the key explicitly.
['scope' => 'project', 'keys' => ['comments.create']],
],
'editor' => [
['scope' => 'project', 'verbs' => ['create', 'edit', 'delete']],
// Timesheets are GLOBAL-scoped (company-wide time logging), so the project verb rule
// above does NOT match them — an editor's own-time capability is granted by explicit
// global keys. Ownership (own vs others) is enforced in the service; the cross-user
// `timesheets.manage` stays manager+ (below).
['scope' => 'global', 'keys' => ['timesheets.view', 'timesheets.create', 'timesheets.edit', 'timesheets.delete']],
],
'manager' => [
['scope' => 'project', 'verbs' => ['*']],
// Managers may INVITE users (the NewUser screen is manager+). The client-scoping
// — a manager can only invite into their own client — stays in the controller/
// service, not here. They CANNOT view the roster, edit, delete, or import users;
// those remain admin+. users.* are company-wide, so this is an explicit global
// key grant rather than a 'create' verb rule (a verb rule would also need a global
// scope and is fine, but the explicit key documents that ONLY create is intended).
// timesheets.manage (company-wide invoicing/reports/others' time) is manager+; the
// editor keys above are inherited up the hierarchy.
//
// projects.create/edit/delete are GLOBAL-scoped company actions (managers create/edit/
// delete ANY project — the legacy controllers gate on the global manager role via
// authOrRedirect([...], forceGlobalRoleCheck: true)). Being global, they are NOT matched
// by the editor's `scope:project create/edit/delete` rule, so they stay manager+ here;
// projects.view is a project-scoped verb and auto-grants readonly+ separately.
['scope' => 'global', 'keys' => ['users.create', 'timesheets.manage', 'projects.create', 'projects.edit', 'projects.delete']],
],
'admin' => [
['scope' => 'any', 'verbs' => ['*'], 'exclude' => ['company.settings.*']],
// Admins may view AND edit the company-settings screen (incl. logo) per policy. The
// exclude above keeps any OTHER future company.settings.* owner-only by default;
// these two keys are granted to admins explicitly.
['scope' => 'global', 'keys' => ['company.settings.view', 'company.settings.edit']],
],
'owner' => [['scope' => 'any', 'verbs' => ['*']]],
];
/** @return array<int, array{name: string, displayName: string, level: int}> */
public static function roles(): array
{
return self::ROLES;
}
/**
* The default permission keys granted to $roleName, given the full discovered catalog.
* Unions this role's rules with every lower role in the hierarchy.
*
* @param array<int, Permission> $catalog
* @return array<int, string>
*/
public static function grantsFor(string $roleName, array $catalog): array
{
$level = self::levelOf($roleName);
$rules = [];
foreach (self::ROLES as $role) {
if ($role['level'] <= $level) {
foreach (self::GRANTS[$role['name']] as $rule) {
$rules[] = $rule;
}
}
}
$keys = [];
foreach ($catalog as $permission) {
foreach ($rules as $rule) {
if (self::matches($permission, $rule)) {
$keys[$permission->key] = true;
break;
}
}
}
return array_keys($keys);
}
private static function levelOf(string $roleName): int
{
foreach (self::ROLES as $role) {
if ($role['name'] === $roleName) {
return $role['level'];
}
}
return 0;
}
/**
* @param array{scope: string, verbs?: array<int, string>, keys?: array<int, string>, exclude?: array<int, string>} $rule
*/
private static function matches(Permission $permission, array $rule): bool
{
if ($rule['scope'] === 'project' && ! $permission->projectScoped) {
return false;
}
if ($rule['scope'] === 'global' && $permission->projectScoped) {
return false;
}
// A rule matches by EITHER an explicit key allow-list (for permissions that don't follow
// the verb convention) OR by verb. Compute the base match first...
if (isset($rule['keys'])) {
$matched = in_array($permission->key, $rule['keys'], true);
} else {
$verb = Str::afterLast($permission->key, '.');
$matched = ($rule['verbs'] ?? []) === ['*'] || in_array($verb, $rule['verbs'] ?? [], true);
}
if (! $matched) {
return false;
}
// ...then ALWAYS apply the exclude list, so an `exclude` alongside `keys` is honored (a
// `keys` rule previously returned early and bypassed the exclude, risking an over-grant).
foreach ($rule['exclude'] ?? [] as $excluded) {
if ($excluded === $permission->key) {
return false;
}
if (str_ends_with($excluded, '.*') && str_starts_with($permission->key, substr($excluded, 0, -1))) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Leantime\Core\Auth\Permissions;
use Illuminate\Support\Str;
/**
* Immutable description of a single capability in the permission vocabulary.
*
* A permission is just a named, dotted `domain.action` verb (e.g. `tickets.create`,
* `company.settings.edit`) plus presentation/scope metadata. Crucially it carries **no
* role information** — which roles hold a permission is a separate, centrally-managed
* concern (see {@see DefaultRolePermissions} for the built-in defaults and the
* `zp_role_permissions` table / admin UI for runtime assignments). A domain declares only
* *what verbs exist*; it never declares *who gets them*.
*
* `projectScoped` distinguishes capabilities evaluated against a specific project's role
* (most content actions) from company-wide capabilities (user/client management, company
* settings) that resolve against the global role.
*/
final class Permission
{
/**
* @param string $key Dotted `domain.action` identifier, e.g. `tickets.create`.
* @param string $displayName Human-readable label shown in the role/permission UI.
* @param bool $projectScoped Whether this capability is evaluated per-project (true)
* or company-wide against the global role (false).
*/
public function __construct(
public readonly string $key,
public readonly string $displayName,
public readonly bool $projectScoped = true,
) {}
/**
* The owning domain — the first dotted segment of the key (e.g. `company` for
* `company.settings.edit`).
*/
public function domain(): string
{
return Str::before($this->key, '.');
}
/**
* The action — everything after the first dotted segment (e.g. `settings.edit`
* for `company.settings.edit`, `create` for `tickets.create`).
*/
public function action(): string
{
return Str::after($this->key, '.');
}
}

View File

@@ -0,0 +1,239 @@
<?php
namespace Leantime\Core\Auth\Permissions;
use Illuminate\Support\Facades\Log;
use Leantime\Core\Exceptions\AuthorizationException;
use ReflectionException;
use ReflectionMethod;
/**
* Reads the {@see RequiresPermission} attribute off a resolved action/method and enforces it.
*
* Shared by the entry points so the declaration means the same everywhere:
* - {@see \Leantime\Core\Controller\Frontcontroller::executeAction()} — legacy convention routes,
* - {@see CheckPermissions} middleware — native Laravel routes,
* - {@see \Leantime\Domain\Api\Controllers\Jsonrpc::executeApiRequest()} — JSON-RPC.
*
* Safety properties:
* - A method WITHOUT the attribute is a complete no-op — it never touches the session, DB,
* or permission engine. So wiring the hooks in is inert until methods are annotated.
* - Audit mode (the default, `config('permissions.enforce')` falsy) only LOGS would-be
* denials instead of blocking, so enforcement can be rolled out and observed per domain
* before flipping to blocking.
*/
class PermissionEnforcer
{
/** @var array<string, RequiresPermission|null> Memoized attribute lookups per class::method. */
private array $cache = [];
/** @var array<string, bool> 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<string, mixed> $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<string, mixed> $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;
}
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace Leantime\Core\Auth\Permissions;
use Illuminate\Contracts\Container\Container;
use Illuminate\Support\Facades\Cache;
/**
* Discovers and aggregates the permission vocabulary declared by every domain (and folder
* plugin) implementing {@see ProvidesPermissions}.
*
* Mirrors {@see \Leantime\Core\Events\EventDispatcher::discoverListeners()}: it globs the
* conventional locations, caches the discovered provider class list on the shared
* `installation` store outside debug mode, then instantiates each provider and merges its
* {@see Permission} declarations into a single keyed catalog. The catalog is the in-memory
* source of truth that `permissions:sync` writes into the database.
*/
class PermissionRegistry
{
private const PROVIDER_CACHE_KEY = 'permissionProviders';
/** @var array<string, Permission>|null */
private ?array $catalog = null;
public function __construct(private Container $container) {}
/**
* The full catalog keyed by permission key (e.g. 'tickets.create' => Permission).
*
* @return array<string, Permission>
*/
public function all(): array
{
if ($this->catalog !== null) {
return $this->catalog;
}
$this->catalog = [];
foreach ($this->providerClasses() as $class) {
$provider = $this->container->make($class);
if (! $provider instanceof ProvidesPermissions) {
continue;
}
foreach ($provider->permissions() as $permission) {
$this->catalog[$permission->key] = $permission;
}
}
return $this->catalog;
}
public function get(string $key): ?Permission
{
return $this->all()[$key] ?? null;
}
/** Drop the in-memory and cross-request provider caches (call on plugin enable/disable). */
public function flush(): void
{
$this->catalog = null;
Cache::store('installation')->forget(self::PROVIDER_CACHE_KEY);
}
/**
* The discovered provider class names. Cached on the installation store outside debug
* mode, exactly like EventDispatcher's 'domainEvents'.
*
* @return array<int, string>
*/
private function providerClasses(): array
{
if ((bool) config('debug') === false) {
return Cache::store('installation')->rememberForever(self::PROVIDER_CACHE_KEY, fn () => $this->scanProviderClasses());
}
return $this->scanProviderClasses();
}
/**
* Glob the conventional provider locations and resolve each to a FQCN.
*
* @return array<int, string>
*/
private function scanProviderClasses(): array
{
$patterns = [
APP_ROOT.'/app/Domain/*/Permissions/*Permissions.php',
APP_ROOT.'/app/Plugins/*/Permissions/*Permissions.php',
];
$classes = [];
foreach ($patterns as $pattern) {
foreach ((array) glob($pattern) as $file) {
$class = $this->classFromPath((string) $file);
if ($class !== null) {
$classes[] = $class;
}
}
}
return $classes;
}
/**
* Map an app file path to its FQCN under the Leantime namespace
* (app/Domain/Tickets/Permissions/TicketsPermissions.php ->
* Leantime\Domain\Tickets\Permissions\TicketsPermissions).
*/
private function classFromPath(string $file): ?string
{
$relative = str_replace(APP_ROOT.'/app/', '', $file);
$relative = substr($relative, 0, -strlen('.php'));
$class = 'Leantime\\'.str_replace('/', '\\', $relative);
return class_exists($class) ? $class : null;
}
}

View File

@@ -0,0 +1,288 @@
<?php
namespace Leantime\Core\Auth\Permissions;
use Illuminate\Database\ConnectionInterface;
use Leantime\Core\Db\Db as DbCore;
/**
* Data access for the native permission engine (no ORM — Laravel query builder over the
* `zp_roles`, `zp_permissions`, `zp_role_permissions` tables).
*
* Roles are the DB-backed definitions (built-ins + custom); permissions are the synced
* `domain.action` vocabulary; the grant map links them. This repository is consumed by
* {@see PermissionService} (read path, cached), {@see PermissionSeeder} (built-in seeding +
* vocabulary sync), `permissions:sync`, and the future role-management UI (write path).
*/
class PermissionRepository
{
private ConnectionInterface $db;
public function __construct(DbCore $db)
{
$this->db = $db->getConnection();
}
// ------------------------------------------------------------------
// Roles
// ------------------------------------------------------------------
/** @return array<string, mixed>|null */
public function getRoleByName(string $name): ?array
{
$row = $this->db->table('zp_roles')->where('name', $name)->first();
return $row ? (array) $row : null;
}
/** @return array<string, mixed>|null */
public function getRoleById(int $id): ?array
{
$row = $this->db->table('zp_roles')->where('id', $id)->first();
return $row ? (array) $row : null;
}
/**
* All roles ordered by hierarchy level (ascending).
*
* @return array<int, array<string, mixed>>
*/
public function getAllRoles(): array
{
return $this->db->table('zp_roles')
->orderBy('level')
->get()
->map(fn ($row) => (array) $row)
->all();
}
/**
* Insert or update a role keyed by its (unique) name. Returns the role id.
* Built-in roles pass isSystem=true so the admin UI can protect them.
*/
public function upsertRole(string $name, string $displayName, int $level, bool $isSystem = false, ?string $description = null): int
{
$now = dtHelper()->userNow()->formatDateTimeForDb();
$existing = $this->getRoleByName($name);
if ($existing !== null) {
$this->db->table('zp_roles')->where('id', $existing['id'])->update([
'displayName' => $displayName,
'level' => $level,
'isSystem' => $isSystem ? 1 : 0,
'description' => $description,
'modified' => $now,
]);
return (int) $existing['id'];
}
return (int) $this->db->table('zp_roles')->insertGetId([
'name' => $name,
'displayName' => $displayName,
'level' => $level,
'isSystem' => $isSystem ? 1 : 0,
'description' => $description,
'createdOn' => $now,
'modified' => $now,
]);
}
/** @param array<string, mixed> $data */
public function createRole(array $data): int
{
return $this->upsertRole(
(string) $data['name'],
(string) ($data['displayName'] ?? $data['name']),
(int) ($data['level'] ?? 20),
(bool) ($data['isSystem'] ?? false),
$data['description'] ?? null,
);
}
/** @param array<string, mixed> $data */
public function updateRole(int $id, array $data): bool
{
$data['modified'] = dtHelper()->userNow()->formatDateTimeForDb();
return (bool) $this->db->table('zp_roles')->where('id', $id)->update($data);
}
/** Deletes a role and its grants. Callers must block deletion of isSystem roles. */
public function deleteRole(int $id): bool
{
$this->db->table('zp_role_permissions')->where('roleId', $id)->delete();
return (bool) $this->db->table('zp_roles')->where('id', $id)->delete();
}
// ------------------------------------------------------------------
// Permissions (the vocabulary)
// ------------------------------------------------------------------
/** @return array<int, array<string, mixed>> */
public function getAllPermissions(): array
{
return $this->db->table('zp_permissions')
->orderBy('permissionKey')
->get()
->map(fn ($row) => (array) $row)
->all();
}
/** @return array<string, mixed>|null */
public function getPermissionByKey(string $key): ?array
{
$row = $this->db->table('zp_permissions')->where('permissionKey', $key)->first();
return $row ? (array) $row : null;
}
/**
* Idempotently upsert the discovered vocabulary into zp_permissions, keyed by
* permissionKey. Returns the full list of synced keys (for optional pruning).
*
* @param array<int, array{key:string, domain:string, action:string, label:string, projectScoped:bool}> $definitions
* @return array<int, string> The synced permission keys.
*/
public function syncPermissions(array $definitions): array
{
$now = dtHelper()->userNow()->formatDateTimeForDb();
$keys = [];
foreach ($definitions as $def) {
$keys[] = $def['key'];
$row = [
'domain' => $def['domain'],
'action' => $def['action'],
'label' => $def['label'],
'isProjectScoped' => $def['projectScoped'] ? 1 : 0,
'modified' => $now,
];
if ($this->getPermissionByKey($def['key']) !== null) {
$this->db->table('zp_permissions')->where('permissionKey', $def['key'])->update($row);
continue;
}
$this->db->table('zp_permissions')->insert($row + [
'permissionKey' => $def['key'],
'createdOn' => $now,
]);
}
return $keys;
}
/**
* Remove permissions (and their grants) whose key is not in $keepKeys. Returns the
* number of pruned permissions. Used by `permissions:sync --prune`.
*
* @param array<int, string> $keepKeys
*/
public function pruneOrphanPermissions(array $keepKeys): int
{
$orphans = $this->db->table('zp_permissions')
->when($keepKeys !== [], fn ($q) => $q->whereNotIn('permissionKey', $keepKeys))
->pluck('id')
->all();
if ($orphans === []) {
return 0;
}
$this->db->table('zp_role_permissions')->whereIn('permissionId', $orphans)->delete();
return $this->db->table('zp_permissions')->whereIn('id', $orphans)->delete();
}
// ------------------------------------------------------------------
// Grants (role <-> permission map)
// ------------------------------------------------------------------
/**
* The full role -> [permissionKey, ...] map driving runtime checks. One JOIN; the
* caller ({@see PermissionService}) caches the result.
*
* @return array<string, array<int, string>>
*/
public function getRolePermissionMap(): array
{
$rows = $this->db->table('zp_role_permissions as rp')
->join('zp_roles as r', 'r.id', '=', 'rp.roleId')
->join('zp_permissions as p', 'p.id', '=', 'rp.permissionId')
->select('r.name as roleName', 'p.permissionKey as permissionKey')
->get();
$map = [];
foreach ($rows as $row) {
$map[$row->roleName][] = $row->permissionKey;
}
return $map;
}
/**
* Replace a role's grants with exactly the given permission keys (transactional).
*
* @param array<int, string> $permissionKeys
*/
public function replaceRolePermissions(int $roleId, array $permissionKeys): void
{
$this->db->transaction(function () use ($roleId, $permissionKeys) {
$this->db->table('zp_role_permissions')->where('roleId', $roleId)->delete();
if ($permissionKeys === []) {
return;
}
$ids = $this->db->table('zp_permissions')
->whereIn('permissionKey', $permissionKeys)
->pluck('id')
->all();
$rows = array_map(fn ($id) => ['roleId' => $roleId, 'permissionId' => $id], $ids);
if ($rows !== []) {
$this->db->table('zp_role_permissions')->insert($rows);
}
});
}
/** Grant a single permission to a role (no-op if already granted). */
public function grant(int $roleId, string $permissionKey): void
{
$permission = $this->getPermissionByKey($permissionKey);
if ($permission === null) {
return;
}
$exists = $this->db->table('zp_role_permissions')
->where('roleId', $roleId)
->where('permissionId', $permission['id'])
->exists();
if (! $exists) {
$this->db->table('zp_role_permissions')->insert([
'roleId' => $roleId,
'permissionId' => $permission['id'],
]);
}
}
/** Revoke a single permission from a role. */
public function revoke(int $roleId, string $permissionKey): void
{
$permission = $this->getPermissionByKey($permissionKey);
if ($permission === null) {
return;
}
$this->db->table('zp_role_permissions')
->where('roleId', $roleId)
->where('permissionId', $permission['id'])
->delete();
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace Leantime\Core\Auth\Permissions;
/**
* Seeds the database-backed permission system from code declarations.
*
* Two idempotent operations:
* - {@see syncDiscoveredPermissions()} writes the discovered `domain.action` vocabulary
* into zp_permissions. Safe to run anytime; never touches role grants, so administrator
* customizations survive a re-sync.
* - {@see seedBuiltInRoles()} upserts the six built-in roles and ADDITIVELY grants each its
* default permissions, resolved from the central {@see DefaultRolePermissions} matrix
* against the discovered catalog. Additive grants never remove an administrator's edits.
*
* Vocabulary must be synced before grants can reference it, so the install migration calls
* sync first, then seed.
*/
class PermissionSeeder
{
public function __construct(
private PermissionRepository $repo,
private PermissionRegistry $registry,
private PermissionService $permissions,
) {}
/**
* Upsert the discovered vocabulary into zp_permissions and bust the engine cache.
*
* @return array<int, string> The synced permission keys.
*/
public function syncDiscoveredPermissions(): array
{
$definitions = array_map(
fn (Permission $p): array => [
'key' => $p->key,
'domain' => $p->domain(),
'action' => $p->action(),
'label' => $p->displayName,
'projectScoped' => $p->projectScoped,
],
array_values($this->registry->all()),
);
$keys = $this->repo->syncPermissions($definitions);
$this->permissions->flushCache();
return $keys;
}
/**
* Upsert the six built-in roles and additively grant their default permissions from the
* central {@see DefaultRolePermissions} matrix.
*/
public function seedBuiltInRoles(): void
{
$catalog = array_values($this->registry->all());
$roleIds = [];
foreach (DefaultRolePermissions::roles() as $role) {
$roleIds[$role['name']] = $this->repo->upsertRole(
$role['name'],
$role['displayName'],
$role['level'],
isSystem: true,
);
}
foreach (DefaultRolePermissions::roles() as $role) {
foreach (DefaultRolePermissions::grantsFor($role['name'], $catalog) as $permissionKey) {
$this->repo->grant($roleIds[$role['name']], $permissionKey);
}
}
$this->permissions->flushCache();
}
}

View File

@@ -0,0 +1,158 @@
<?php
namespace Leantime\Core\Auth\Permissions;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Leantime\Core\Auth\Contracts\ChecksProjectAccess;
use Leantime\Core\Auth\RoleResolver;
use Leantime\Core\Exceptions\AuthorizationException;
use Leantime\Domain\Auth\Models\Roles;
/**
* The capability engine: the single runtime answer to "may the current user do X?".
*
* Consumed everywhere through one method, {@see currentUserCan()}: the JSON-RPC
* dispatcher and controller bases (via {@see RequiresPermission}), Blade `@can` (via the
* Gate::before bridge), the menu builder, and in-method `$this->authorize()` helpers.
*
* Two concerns are kept strictly separate:
* - CAPABILITY — does the user's effective role hold the permission? Resolved against the
* cached role->permission grant map. Effective role is project-aware (see {@see RoleResolver}).
* - DATA ACCESS — for project-scoped permissions targeting a concrete project, is the user
* actually a member of (or otherwise able to access) that project? Admin/owner bypass.
*
* Ownership and other entity-specific checks deliberately live in callers, not here.
*/
class PermissionService
{
private const MAP_CACHE_KEY = 'leantime.permissionMap';
private const META_CACHE_KEY = 'leantime.permissionMeta';
public function __construct(
private PermissionRepository $repo,
private RoleResolver $roles,
private ChecksProjectAccess $projectAccess,
) {}
/**
* Whether $roleName holds $permissionKey, by flat lookup on the cached grant map.
*/
public function roleHasPermission(string $roleName, string $permissionKey): bool
{
return in_array($permissionKey, $this->map()[$roleName] ?? [], true);
}
/**
* The authorization decision. Resolves the effective role (project-aware for
* project-scoped permissions), checks the grant map, then — only for project-scoped
* permissions against a concrete project — ANDs in project data access.
*
* @param string $permissionKey A `domain.action` key.
* @param int|null $projectId The project the acted-on entity belongs to (for project-scoped checks).
* @param bool|null $forceGlobal Force the global-role scope (company-wide screens). Null = inferred from the permission.
*/
public function currentUserCan(string $permissionKey, ?int $projectId = null, ?bool $forceGlobal = null): bool
{
$projectScoped = $this->isProjectScoped($permissionKey);
$useGlobal = $forceGlobal === true || ! $projectScoped;
if ($useGlobal) {
$role = $this->roles->effectiveRole(true);
} elseif ($projectId !== null) {
$role = $this->roles->effectiveRoleForProject($projectId);
} else {
$role = $this->roles->effectiveRole(false);
}
if ($role === false || ! $this->roleHasPermission($role, $permissionKey)) {
return false;
}
// Capability granted. Enforce project data access for project-scoped checks.
if ($projectScoped && $projectId !== null && ! $this->canAccessAllProjects()) {
return $this->projectAccess->isUserAssignedToProject((int) session('userdata.id'), $projectId);
}
return true;
}
/**
* Authorize or throw. Services should call this instead of returning false on denial,
* so the failure maps cleanly to 403 (web) / RPC -32001.
*
* @throws AuthorizationException
*/
public function authorize(string $permissionKey, ?int $projectId = null, ?bool $forceGlobal = null): void
{
if (! $this->currentUserCan($permissionKey, $projectId, $forceGlobal)) {
// Keep the permission key server-side only (audit/debug); the exception's
// client-facing message stays generic so we don't expose authz vocabulary.
Log::info('Authorization denied for permission "'.$permissionKey.'" (user '.(session('userdata.id') ?? 'guest').')');
throw new AuthorizationException;
}
}
/**
* Whether $permissionKey is part of the synced vocabulary. Used by the Gate::before
* bridge to defer (return null) on dotted abilities it does not own.
*/
public function isManagedPermission(string $permissionKey): bool
{
return isset($this->meta()[$permissionKey]);
}
/** Whether a permission is evaluated per-project (true) or company-wide (false). */
public function isProjectScoped(string $permissionKey): bool
{
return (bool) ($this->meta()[$permissionKey]['projectScoped'] ?? false);
}
/** Forget the cached grant map + vocabulary meta. Call after any role/permission write. */
public function flushCache(): void
{
Cache::store('installation')->forget(self::MAP_CACHE_KEY);
Cache::store('installation')->forget(self::META_CACHE_KEY);
}
/**
* Admin/owner access every project (mirrors getProjectsUserHasAccessTo's bypass), so
* they skip the per-project membership check.
*/
private function canAccessAllProjects(): bool
{
$globalRole = $this->roles->globalRole();
return $globalRole === Roles::$owner || $globalRole === Roles::$admin;
}
/**
* The role -> [permissionKey, ...] grant map, cached on the shared installation store
* (file/Redis) so all workers share it; busted via {@see flushCache()}.
*
* @return array<string, array<int, string>>
*/
private function map(): array
{
return Cache::store('installation')->rememberForever(self::MAP_CACHE_KEY, fn () => $this->repo->getRolePermissionMap());
}
/**
* Vocabulary meta (key => ['projectScoped' => bool]), cached alongside the grant map.
*
* @return array<string, array{projectScoped: bool}>
*/
private function meta(): array
{
return Cache::store('installation')->rememberForever(self::META_CACHE_KEY, function () {
$meta = [];
foreach ($this->repo->getAllPermissions() as $permission) {
$meta[$permission['permissionKey']] = ['projectScoped' => (bool) $permission['isProjectScoped']];
}
return $meta;
});
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace Leantime\Core\Auth\Permissions;
use Illuminate\Contracts\Auth\Access\Gate as GateContract;
use Illuminate\Support\ServiceProvider;
use Leantime\Core\Auth\Contracts\ChecksProjectAccess;
use Leantime\Core\Domains\BaseService;
use Leantime\Domain\Projects\Services\Projects;
/**
* Wires the native permission engine: singletons, the project-access abstraction binding,
* the Gate bridge, and dependency injection for {@see BaseService} subclasses.
*
* Registered in laravelConfig's provider list. Keeping this separate from
* AuthenticationServiceProvider keeps authn (guards/tokens) and authz (permissions) cleanly
* apart.
*/
class PermissionServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(PermissionService::class);
$this->app->singleton(PermissionEnforcer::class);
$this->app->singleton(PermissionRegistry::class);
// The engine depends on a narrow project-access abstraction, not the Projects
// god-service. A shared singleton so RoleResolver and PermissionService reuse one
// instance and we don't construct Projects more than once.
$this->app->singleton(ChecksProjectAccess::class, fn ($app) => $app->make(Projects::class));
}
public function boot(): void
{
$this->registerPermissionGate();
$this->injectBaseServiceDependencies();
}
/**
* Bridge Laravel's authorization Gate to the engine. Leantime's authenticated user is a
* stdClass (no `->can()`), so a single Gate::before hook resolves every `domain.action`
* ability through {@see PermissionService::currentUserCan()} — making `@can('tickets.create')`,
* `Gate::allows()`, and the `can` middleware all speak the one vocabulary. Non-permission
* abilities (no dot, or not in the synced catalog) return null so other gates still work;
* resolution is deferred into the closure so Projects isn't constructed at boot.
*/
protected function registerPermissionGate(): void
{
$container = $this->app;
$this->app->make(GateContract::class)->before(function ($user, string $ability, array $arguments = []) use ($container) {
if (! str_contains($ability, '.')) {
return null;
}
try {
$permissions = $container->make(PermissionService::class);
if (! $permissions->isManagedPermission($ability)) {
return null;
}
// Only treat the first gate argument as a project id when it is numeric;
// @can / Gate::allows may pass models or other objects. Otherwise fall back
// to session scope (null).
$projectId = isset($arguments[0]) && is_numeric($arguments[0]) ? (int) $arguments[0] : null;
return $permissions->currentUserCan($ability, $projectId);
} catch (\Throwable) {
// Permission tables not ready yet (e.g. pre-migration install) — defer.
return null;
}
});
}
/**
* Wire PermissionService into every service that extends {@see BaseService}, without forcing
* subclass constructors to wire it. The afterResolving callback fires for any resolved instance
* that is `instanceof BaseService`.
*
* We wire a LAZY resolver, not the instance: a BaseService can sit inside PermissionService's
* own dependency graph (Files is reached via PermissionService → ChecksProjectAccess → Projects
* → Files), so eagerly calling `make(PermissionService)` here would re-enter PermissionService's
* half-built construction and recurse infinitely (stack overflow at boot). Resolving lazily on
* first authorize()/can() defers it until the singleton has been built.
*/
protected function injectBaseServiceDependencies(): void
{
$this->app->afterResolving(BaseService::class, function (BaseService $service, $app) {
$service->setPermissionServiceResolver(fn () => $app->make(PermissionService::class));
});
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Leantime\Core\Auth\Permissions;
/**
* Contract implemented by each domain's (and plugin's) permission catalog.
*
* Implementations live at `app/Domain/{Domain}/Permissions/{Domain}Permissions.php`
* (and the plugin equivalent) and are auto-discovered at boot by
* {@see PermissionRegistry}, mirroring how `register.php` event listeners are
* discovered. The declared {@see Permission} objects are the single source of truth
* for the `domain.action` vocabulary — `permissions:sync` writes them into the
* `zp_permissions` table so an administrator can assign them to roles.
*
* Concrete implementations should also expose typed string constants
* (e.g. `const CREATE = 'tickets.create';`) so call sites reference constants
* rather than magic strings.
*/
interface ProvidesPermissions
{
/**
* The capabilities this provider contributes to the vocabulary.
*
* @return array<int, Permission>
*/
public function permissions(): array;
/**
* The domain key these permissions belong to (e.g. `tickets`). Used for grouping
* in the admin UI and as the `zp_permissions.domain` column value.
*/
public function domain(): string;
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Leantime\Core\Auth\Permissions;
use Attribute;
/**
* Declares the permission required to invoke a controller action or an `@api`
* service method.
*
* The same declaration is enforced at every entry point to the service layer, all reading
* it through {@see PermissionEnforcer}:
* - Legacy convention routes: {@see \Leantime\Core\Controller\Frontcontroller::executeAction()}.
* - Native Laravel routes: the {@see CheckPermissions} middleware.
* - JSON-RPC: {@see \Leantime\Domain\Api\Controllers\Jsonrpc::executeApiRequest()} on the
* resolved service method (RPC bypasses the controller gate, so this is what secures it).
*
* On denial an {@see \Leantime\Core\Exceptions\AuthorizationException} is thrown, which the
* global handler renders as 403 on the web and `JsonRpcErrorResponse::fromException`
* maps to RPC error -32001.
*
* How the project scope is resolved (mutually informative):
* - `projectIdParam: 'projectId'` — the enforcer reads that request param and runs the
* full project-scoped check. Use when the project id is a clean top-level argument.
* - `global: true` — a company-wide capability (users/clients/settings); the enforcer
* checks against the global role, not a project.
* - `entityScoped: true` — the project comes from an entity the method loads itself
* (e.g. `$ticket->projectId`), which the enforcer can't see beforehand. The attribute is
* then a declared-coverage marker and the method body MUST call
* `$this->authorize($perm, $entity->projectId)` to do the precise check.
* - none of the above — falls back to the current session project (`session('currentProject')`),
* appropriate for session-scoped views.
*/
#[Attribute(Attribute::TARGET_METHOD)]
final class RequiresPermission
{
/**
* @param string $permission The required `domain.action` key (use a domain
* permission constant, e.g. `TicketsPermissions::CREATE`).
* @param string|null $projectIdParam Name of the request param holding the project id.
* @param bool $global Company-wide capability — check the global role, not a project.
* @param bool $entityScoped Project is derived from an entity the method loads; the
* enforcer defers and the method self-authorizes in its body.
*/
public function __construct(
public readonly string $permission,
public readonly ?string $projectIdParam = null,
public readonly bool $global = false,
public readonly bool $entityScoped = false,
) {}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace Leantime\Core\Auth;
use Leantime\Core\Auth\Contracts\ChecksProjectAccess;
use Leantime\Domain\Auth\Models\Roles;
use Leantime\Domain\Auth\Services\Auth as AuthService;
/**
* Single home for resolving a user's EFFECTIVE role, in both scopes Leantime cares about.
*
* Leantime roles are project-scoped: a user can hold one role globally
* (`session('userdata.role')`) and a different role inside a given project
* (`zp_relationuserproject.projectRole`). Authorization correctness depends on picking
* the right one:
* - {@see effectiveRole()} resolves the role for the CURRENT SESSION project — the
* historical behavior of `Auth::getRoleToCheck()`, which this delegates to (no
* duplication). Use it for "is the current screen allowed" checks.
* - {@see effectiveRoleForProject()} resolves the role for a SPECIFIC project — the
* only correct basis for authorizing a mutation on an entity that may live outside
* the session project. It centralizes the logic previously private to
* `Tickets::userIsAtLeastForProject()`.
*
* This is infrastructure shared across every domain, hence it lives in Core/Auth. It
* still references the Domain-layer `Roles` definitions and `Projects` service for now;
* those are stable value/lookup surfaces and the coupling is intentional pragmatism
* (the broader Roles->Core move is deferred).
*/
class RoleResolver
{
public function __construct(private ChecksProjectAccess $projectAccess) {}
/** The current user's global role string, or null when not authenticated. */
public function globalRole(): ?string
{
$role = session('userdata.role');
return ($role === null || $role === '') ? null : $role;
}
/**
* Effective role for the current session project (delegates to the existing
* dual-scope resolution). `$forceGlobal` short-circuits to the global role for
* company-wide screens (users/clients/settings).
*/
public function effectiveRole(bool $forceGlobal = false): string|false
{
return AuthService::getRoleToCheck($forceGlobal);
}
/**
* Effective role for a specific project. Manager/admin/owner keep their global role
* everywhere; otherwise the explicit project role applies, falling back to the
* global role when none is set. Returns false when not authenticated.
*/
public function effectiveRoleForProject(int $projectId): string|false
{
$globalRole = $this->globalRole();
if ($globalRole === null) {
return false;
}
$roles = Roles::getRoles();
$globalKey = array_search($globalRole, $roles, true);
$managerKey = array_search(Roles::$manager, $roles, true);
// Manager and above keep their global role across every project.
if ($globalKey !== false && $managerKey !== false && $globalKey >= $managerKey) {
return $globalRole;
}
$projectRole = $this->projectAccess->getProjectRole((int) session('userdata.id'), $projectId);
// No explicit project role -> inherit the global role.
if ($projectRole === '') {
return $globalRole;
}
// getProjectRole() returns either a numeric role key or, for legacy rows, a role name.
// Resolve a numeric key to its role string; accept an already-valid role name as-is. Anything
// that still can't be resolved falls back to the global role rather than denying a genuine
// member (false -> 403 is worse than granting their inherited global role) (#3618).
$resolvedRole = ctype_digit((string) $projectRole)
? Roles::getRoleString((int) $projectRole)
: (in_array($projectRole, $roles, true) ? $projectRole : false);
return $resolvedRole === false ? $globalRole : $resolvedRole;
}
/**
* True when $effectiveRole ranks at or above $requiredRole in the role hierarchy,
* using the same ordering as {@see Roles::getRoles()}.
*/
public function atLeast(string $requiredRole, string|false $effectiveRole): bool
{
if ($effectiveRole === false) {
return false;
}
$roles = Roles::getRoles();
$requiredKey = array_search($requiredRole, $roles, true);
$effectiveKey = array_search($effectiveRole, $roles, true);
return $requiredKey !== false && $effectiveKey !== false && $effectiveKey >= $requiredKey;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace Leantime\Core\Auth\Tokens;
use Illuminate\Support\ServiceProvider;
use Laravel\Sanctum\Contracts\HasAbilities;
use Laravel\Sanctum\Sanctum as SanctumBase;
use Leantime\Domain\Auth\Services\AccessToken;
class SanctumServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(HasAbilities::class, AccessToken::class);
}
public function boot(): void
{
// Use our custom token model
// @phpstan-ignore-next-line argument.type
SanctumBase::usePersonalAccessTokenModel(AccessToken::class);
}
}