Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/content/foundation/helpers.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ Blade — without importing anything.
named route. Use it to show/hide permission-gated UI. It applies the same
conventional and explicit permission aliases as route middleware. Routes that
opt out of permission middleware are allowed; protected routes defer to the
gate. See
gate. The guard is guessed from the route's `auth` middleware (falling back
to `admins`); pass one explicitly as the second argument to override. See
[Datatables](/packages/datatables/overview) for the common use.

```php
Expand Down
18 changes: 14 additions & 4 deletions docs/content/foundation/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,20 @@ settings, so changing them takes effect immediately. See

## Route permission gating

Dashboard routes are gated by their **route name**, treated as a permission
name. A request is allowed when the current admin passes the gate for that route
name; otherwise it is aborted with `403`. Named routes are denied when no gate
grants the resolved permission, while unnamed routes always pass.
Dashboard routes — web and API alike — are gated by their **route name**,
treated as a permission name. A request is allowed when the current admin
passes the gate for that route name; otherwise it is aborted with `403`. Named
routes are denied when no gate grants the resolved permission, while unnamed
routes always pass. The gate authenticates against the guard of the route's
`auth` middleware (falling back to `admins`), so dashboard API routes are
checked against the admin resolved by their API guard.

Dashboard API routes should alias their permission to the matching web route
with `usePermission()` (e.g. `usePermission('dashboard.admins.create')`), so
one permission covers both surfaces instead of a duplicated `api.dashboard.*`
name. Synced permissions are stored under the guard's provider's first
configured guard, so guards sharing a provider (session + API) share one
permission row.

Conventional form and mutation routes share a permission automatically:

Expand Down
2 changes: 1 addition & 1 deletion src/Foundation/src/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public static function configure(?string $basePath = null): ApplicationBuilder
}

if (config('redot.features.dashboard-api.enabled')) {
Route::as('dashboard.')->prefix(config('redot.features.dashboard-api.prefix'))->group(base_path('routes/api/dashboard.php'));
Route::as('dashboard.')->prefix(config('redot.features.dashboard-api.prefix'))->middleware(RoutePermission::class)->group(base_path('routes/api/dashboard.php'));
}
});

Expand Down
18 changes: 12 additions & 6 deletions src/Foundation/src/Commands/SyncPermissionsCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

namespace Redot\Commands;

use Illuminate\Auth\Middleware\Authenticate;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
Expand Down Expand Up @@ -208,16 +207,23 @@ protected function getPermissions(): Collection
}

/**
* Resolve the guard a route authenticates against.
* Resolve the guard a route's permission is stored under.
*
* Guards sharing a provider (e.g. session + API guards over the same
* model) are stamped with the provider's first configured guard, so a
* permission shared across guards is stored once.
*/
protected function guardForRoute($route): string
{
foreach (Route::gatherRouteMiddleware($route) as $middleware) {
if (is_string($middleware) && str_starts_with($middleware, Authenticate::class . ':')) {
return explode(',', substr($middleware, strlen(Authenticate::class) + 1))[0];
$guard = route_guard($route);
$provider = config("auth.guards.{$guard}.provider");

foreach (config('auth.guards', []) as $name => $config) {
if ($provider !== null && ($config['provider'] ?? null) === $provider) {
return $name;
}
}

return 'admins';
return $guard;
}
}
4 changes: 2 additions & 2 deletions src/Foundation/src/Http/Middleware/RoutePermission.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ class RoutePermission
*/
public function handle(Request $request, Closure $next): Response
{
$name = $request->route()->getName();
$route = $request->route();

if (! $name || route_allowed($request->route())) {
if (! $route->getName() || route_allowed($route)) {
return $next($request);
}

Expand Down
25 changes: 22 additions & 3 deletions src/Foundation/src/utilities/authorization.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?php

use Illuminate\Auth\Middleware\Authenticate;
use Illuminate\Routing\Route as RoutingRoute;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Request;
Expand All @@ -22,17 +23,35 @@ function route_from_url(string $url): ?string
}
}

/**
* Resolve the guard a route authenticates against.
*/
function route_guard(RoutingRoute $route, string $default = 'admins'): string
{
foreach (Route::gatherRouteMiddleware($route) as $middleware) {
if (is_string($middleware) && str($middleware)->startsWith(Authenticate::class . ':')) {
return str($middleware)->after(':')->before(',')->value();
}
}

return $default;
}

/**
* Check if the gate allows the given permission.
*/
function route_allowed(RoutingRoute|string $route, string $guard = 'admins'): bool
function route_allowed(RoutingRoute|string $route, ?string $guard = null): bool
{
$registeredRoute = PermissionNameResolver::route($route);

// Guess the guard from the route's auth middleware when none is given
$guard ??= $registeredRoute ? route_guard($registeredRoute) : 'admins';

if (! auth($guard)->check()) {
return false;
}

// Check if the route has the RoutePermission middleware, if not, allow access
$registeredRoute = PermissionNameResolver::route($route);
if ($registeredRoute && ! collect(Route::gatherRouteMiddleware($registeredRoute))->contains(RoutePermission::class)) return true;

// Resolve the permission name for the route, reusing the already-resolved route instance
Expand All @@ -47,7 +66,7 @@ function route_allowed(RoutingRoute|string $route, string $guard = 'admins'): bo
/**
* Check if the url is allowed.
*/
function url_allowed(string $url, string $guard = 'admins'): bool
function url_allowed(string $url, ?string $guard = null): bool
{
$urlHost = parse_url($url, PHP_URL_HOST);
$appHost = parse_url(app_url(), PHP_URL_HOST);
Expand Down
25 changes: 25 additions & 0 deletions tests/Feature/Core/Commands/SyncPermissionsCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,31 @@
]);
});

it('stores one permission for guards sharing a provider', function () {
config(['auth.guards.admins-api' => ['driver' => 'session', 'provider' => 'admins']]);

Route::middleware(RoutePermission::class)->group(function () {
Route::post('/permission-test/admins', fn () => 'store')
->middleware('auth:admins')
->name('permission-test.admins.store');

Route::post('/permission-test/api/admins', fn () => 'store')
->middleware('auth:admins-api')
->name('permission-test.api.admins.store')
->usePermission('permission-test.admins.create');
});

$this->artisan('permissions:sync')->assertSuccessful();

expect(Permission::query()
->where('name', 'like', 'permission-test.%')
->get(['name', 'guard_name'])
->map(fn (Permission $permission) => [$permission->name, $permission->guard_name])
->all())->toBe([
['permission-test.admins.create', 'admins'],
]);
});

it('stamps discovered permissions without touching manually created ones', function () {
Route::middleware(RoutePermission::class)->group(function () {
Route::get('/permission-test/users', fn () => 'index')->name('permission-test.users.index');
Expand Down
37 changes: 37 additions & 0 deletions tests/Feature/Core/Middleware/RoutePermissionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,43 @@
$this->post('/permission-test/users/1/suspend')->assertOk();
});

it('authorizes against the guard of the route auth middleware', function () {
config(['auth.guards.admins-api' => ['driver' => 'session', 'provider' => 'admins']]);

Gate::define('permission-test.api.show', fn (User $user) => $user->id === 2);

Route::get('/permission-test/api', fn () => 'api')
->middleware(['auth:admins-api', RoutePermission::class])
->name('permission-test.api.show');

$apiUser = new User;
$apiUser->setAttribute('id', 2);

$this->actingAs($apiUser, 'admins-api');

$this->get('/permission-test/api')->assertOk();

// UI checks guess the same guard when none is given
Route::getRoutes()->refreshNameLookups();

expect(route_allowed('permission-test.api.show'))->toBeTrue();
});

it('denies api-guard routes when no gate grants their permission', function () {
config(['auth.guards.admins-api' => ['driver' => 'session', 'provider' => 'admins']]);

Route::get('/permission-test/api-denied', fn () => 'denied')
->middleware(['auth:admins-api', RoutePermission::class])
->name('permission-test.api-denied');

$apiUser = new User;
$apiUser->setAttribute('id', 2);

$this->actingAs($apiUser, 'admins-api');

$this->get('/permission-test/api-denied')->assertForbidden();
});

it('preserves the existing unnamed route bypass', function () {
Route::get('/permission-test/unnamed', fn () => 'unnamed')
->middleware(RoutePermission::class);
Expand Down
Loading