Quickstart
Build one protected feature with a permission catalog, a policy, runtime grants, and a protected controller.
Authorization Quickstart
Authorization in Vortos has two layers:
- A user must have the required permission in the runtime RBAC store.
- The policy for that resource must allow the action for the current resource.
The permission answers "may this user attempt this action?". The policy answers "may this user perform it on this resource?".
1. Create a permission catalog
Permission catalogs are discovered at container compile time. They are the only valid source of permissions.
namespace App\Athlete\Infrastructure\Authorization;
use Vortos\Authorization\Attribute\PermissionCatalog;
use Vortos\Authorization\Permission\AbstractPermissionCatalog;
#[PermissionCatalog(resource: 'athletes', group: 'Athletes')]
final class AthletePermissions extends AbstractPermissionCatalog
{
public const ListAny = 'list.any';
public const ReadAny = 'read.any';
public const CreateAny = 'create.any';
public const UpdateOwn = 'update.own';
public const UpdateAny = 'update.any';
public const DeleteAny = 'delete.any';
public static function grants(): array
{
return [
'ROLE_USER' => [
self::ListAny,
self::ReadAny,
],
'ROLE_COACH' => [
self::CreateAny,
self::UpdateOwn,
],
'ROLE_ADMIN' => [
self::UpdateAny,
self::DeleteAny,
],
];
}
public static function meta(): array
{
return [
self::DeleteAny => self::dangerous(
'Delete any athlete',
'Allows deleting athlete records across the application.',
),
];
}
}Catalog constants can be action.scope or full resource.action.scope. With resource: 'athletes', update.own becomes athletes.update.own.
2. Create a policy
Each permission resource needs a policy with the same resource name.
namespace App\Athlete\Infrastructure\Policy;
use Vortos\Authorization\Attribute\AsPolicy;
use Vortos\Authorization\Context\AuthorizationContext;
use Vortos\Authorization\Contract\PolicyInterface;
#[AsPolicy(resource: 'athletes')]
final class AthletePolicy implements PolicyInterface
{
public function can(
AuthorizationContext $auth,
string $action,
string $scope,
mixed $resource = null,
): bool {
return match ($action) {
'list', 'read' => $auth->atLeast('ROLE_USER'),
'create' => $auth->atLeast('ROLE_COACH'),
'update' => $scope === 'own'
? $resource === $auth->user()->id()
: $auth->atLeast('ROLE_ADMIN'),
'delete' => $auth->hasAnyRole(['ROLE_ADMIN', 'ROLE_SUPER_ADMIN']),
default => false,
};
}
}The engine parses athletes.update.own into:
| Segment | Value |
|---|---|
| resource | athletes |
| action | update |
| scope | own |
It then asks the athletes policy whether that action and scope are allowed.
3. Seed default role grants
Default grants from all catalogs are copied into the runtime database with:
php vortos auth:seedPreview the rows first:
php vortos auth:seed --dry-runThis writes to role_permissions. It does not assign roles to users.
4. Assign a runtime role to a user
php vortos auth:user-role:assign user-123 ROLE_COACH \
--actor admin-1 \
--reason "Promoted to coach"This writes to user_roles, increments the user's authorization version, invalidates their permission cache, and records an audit entry.
5. Protect a controller
use Symfony\Component\Routing\Attribute\Route;
use Vortos\Authorization\Attribute\RequiresPermission;
use Vortos\Http\Attribute\AsController;
#[AsController]
final class UpdateAthleteController
{
#[Route('/athletes/{id}', methods: ['PATCH'])]
#[RequiresPermission('athletes.update.own', resourceParam: 'id')]
public function __invoke(string $id): JsonResponse
{
// This only runs after AuthorizationMiddleware allows the request.
}
}resourceParam: 'id' passes the route {id} value to the policy as $resource.
Compile-time validation
If a controller references an unknown permission, container compilation fails. Add the permission to a #[PermissionCatalog] class before using it in #[RequiresPermission].
6. Check the current user permissions
The authorization module exposes:
GET /api/me/permissionsAuthenticated response:
{
"permissions": ["athletes.create.any", "athletes.update.own"],
"roles": ["ROLE_COACH"],
"expandedRoles": ["ROLE_COACH", "ROLE_USER"],
"version": "sha256..."
}Unauthenticated response:
{
"permissions": [],
"roles": [],
"expandedRoles": [],
"version": "sha256..."
}The full request flow
RouterListener
-> AuthMiddleware sets CurrentUserProvider
-> AuthorizationMiddleware reads ControllerPermissionMap
-> PolicyEngine checks permission registry, identity, deny list, authz version, resolver, scoped grants, policy
-> controller executesIf any step denies access, the controller is not called.