OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
133
app/Core/Auth/AuthenticationServiceProvider.php
Normal file
133
app/Core/Auth/AuthenticationServiceProvider.php
Normal 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']
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
26
app/Core/Auth/Contracts/ChecksProjectAccess.php
Normal file
26
app/Core/Auth/Contracts/ChecksProjectAccess.php
Normal 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;
|
||||
}
|
||||
40
app/Core/Auth/Permissions/CheckPermissions.php
Normal file
40
app/Core/Auth/Permissions/CheckPermissions.php
Normal 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);
|
||||
}
|
||||
}
|
||||
187
app/Core/Auth/Permissions/DefaultRolePermissions.php
Normal file
187
app/Core/Auth/Permissions/DefaultRolePermissions.php
Normal 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;
|
||||
}
|
||||
}
|
||||
52
app/Core/Auth/Permissions/Permission.php
Normal file
52
app/Core/Auth/Permissions/Permission.php
Normal 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, '.');
|
||||
}
|
||||
}
|
||||
239
app/Core/Auth/Permissions/PermissionEnforcer.php
Normal file
239
app/Core/Auth/Permissions/PermissionEnforcer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
122
app/Core/Auth/Permissions/PermissionRegistry.php
Normal file
122
app/Core/Auth/Permissions/PermissionRegistry.php
Normal 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;
|
||||
}
|
||||
}
|
||||
288
app/Core/Auth/Permissions/PermissionRepository.php
Normal file
288
app/Core/Auth/Permissions/PermissionRepository.php
Normal 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();
|
||||
}
|
||||
}
|
||||
77
app/Core/Auth/Permissions/PermissionSeeder.php
Normal file
77
app/Core/Auth/Permissions/PermissionSeeder.php
Normal 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();
|
||||
}
|
||||
}
|
||||
158
app/Core/Auth/Permissions/PermissionService.php
Normal file
158
app/Core/Auth/Permissions/PermissionService.php
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
93
app/Core/Auth/Permissions/PermissionServiceProvider.php
Normal file
93
app/Core/Auth/Permissions/PermissionServiceProvider.php
Normal 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));
|
||||
});
|
||||
}
|
||||
}
|
||||
33
app/Core/Auth/Permissions/ProvidesPermissions.php
Normal file
33
app/Core/Auth/Permissions/ProvidesPermissions.php
Normal 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;
|
||||
}
|
||||
51
app/Core/Auth/Permissions/RequiresPermission.php
Normal file
51
app/Core/Auth/Permissions/RequiresPermission.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
107
app/Core/Auth/RoleResolver.php
Normal file
107
app/Core/Auth/RoleResolver.php
Normal 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;
|
||||
}
|
||||
}
|
||||
25
app/Core/Auth/Tokens/SanctumServiceProvider.php
Normal file
25
app/Core/Auth/Tokens/SanctumServiceProvider.php
Normal 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);
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user