Vortos
Authorization

Resource Ownership

Enforce that users can only access resources they own — with an override permission for admins.

Resource Ownership

Ownership middleware enforces that a user owns the resource they are trying to access. It runs at priority 2 — after authorization (3), before feature access (1).

Compile-Time Map

All #[RequiresOwnership] and #[RequiresOwnershipOrPermission] attributes are scanned at compile time. Runtime middleware does a single array lookup — zero reflection.

Implement an Ownership Policy

src/Document/Infrastructure/DocumentOwnershipPolicy.php
use Vortos\Http\Request;
use Vortos\Auth\Contract\UserIdentityInterface;
use Vortos\Authorization\Ownership\Contract\OwnershipPolicyInterface;

// Auto-discovered — no registration needed
final class DocumentOwnershipPolicy implements OwnershipPolicyInterface
{
    public function __construct(
        private DocumentRepository $documents,
    ) {}

    public function isOwner(UserIdentityInterface $identity, string $resourceId): bool
    {
        $document = $this->documents->findById($resourceId);

        if ($document === null) {
            return false; // Resource not found → deny, not 404 (let the controller handle that)
        }

        return $document->getAuthorId() === $identity->id();
    }

    public function getResourceIdFrom(Request $request): string
    {
        return $request->attributes->get('id') ?? '';
    }
}

Require Ownership

use Vortos\Authorization\Ownership\Attribute\RequiresOwnership;

// User must own this resource — period.
#[RequiresOwnership(DocumentOwnershipPolicy::class)]
final class DeleteDocumentController { ... }

#[RequiresOwnership(InvoiceOwnershipPolicy::class)]
final class DownloadInvoiceController { ... }

Ownership OR Override Permission

For resources where admins should be able to bypass the ownership check:

use Vortos\Authorization\Ownership\Attribute\RequiresOwnershipOrPermission;

// User must own the resource OR have this permission (e.g., admins)
#[RequiresOwnershipOrPermission(
    policy: DocumentOwnershipPolicy::class,
    override: 'documents.delete.any',
)]
final class DeleteDocumentController { ... }

// Using an enum for the override permission
enum Permission: string {
    case DocumentsDeleteAny = 'documents.delete.any';
    case InvoicesViewAll    = 'invoices.view.any';
}

#[RequiresOwnershipOrPermission(
    policy: DocumentOwnershipPolicy::class,
    override: Permission::DocumentsDeleteAny,
)]
final class DeleteDocumentController { ... }

Responses

// #[RequiresOwnership] — user is not the owner
HTTP 403 Forbidden
{
  "error": "Forbidden",
  "message": "You do not own this resource."
}

// #[RequiresOwnershipOrPermission] — not owner and lacks override permission
HTTP 403 Forbidden
{
  "error": "Forbidden",
  "message": "You do not own this resource and lack the required permission."
}

Multiple Resource Types

Implement one policy per resource type:

// Each policy handles one resource type
final class DocumentOwnershipPolicy implements OwnershipPolicyInterface { ... }
final class InvoiceOwnershipPolicy implements OwnershipPolicyInterface { ... }
final class CommentOwnershipPolicy implements OwnershipPolicyInterface { ... }

// Each controller references its specific policy
#[RequiresOwnership(DocumentOwnershipPolicy::class)]
final class DeleteDocumentController { ... }

#[RequiresOwnership(InvoiceOwnershipPolicy::class)]
final class DeleteInvoiceController { ... }

getResourceIdFrom

The getResourceIdFrom method extracts the resource identifier from the request. It is called once per request and must be fast:

// Route param
public function getResourceIdFrom(Request $request): string
{
    return $request->attributes->get('id');
}

// Named route param
public function getResourceIdFrom(Request $request): string
{
    return $request->attributes->get('document_id');
}

// From request body
public function getResourceIdFrom(Request $request): string
{
    return $request->toArray()['document_id'] ?? '';
}

Resource Not Found

When isOwner() returns false because the resource doesn't exist, the middleware returns 403, not 404. This is intentional — it does not leak information about whether a resource exists to users who should not see it. The controller should handle the 404 case for authorized users.

Method-Level Attributes

#[RequiresOwnership] and #[RequiresOwnershipOrPermission] can be placed on individual methods, not just the whole controller class:

#[AsController]
#[Route('/documents')]
final class DocumentController
{
    // No ownership check on list
    #[Route('/', methods: ['GET'])]
    public function list(): Response { ... }

    // Only the author can update
    #[Route('/{id}', methods: ['PUT'])]
    #[RequiresOwnership(DocumentOwnershipPolicy::class)]
    public function update(string $id): Response { ... }

    // Author can delete, or someone with documents.delete.any
    #[Route('/{id}', methods: ['DELETE'])]
    #[RequiresOwnershipOrPermission(
        policy: DocumentOwnershipPolicy::class,
        override: 'documents.delete.any',
    )]
    public function delete(string $id): Response { ... }
}

Method-level takes precedence over class-level when both are present.

Middleware Priority

priority 6: AuthMiddleware            — authenticate
priority 5: TwoFactorMiddleware       — 2FA gate
priority 4: RateLimitUser             — per-user rate limits
priority 3: AuthorizationMiddleware   — check permissions
priority 2: OwnershipMiddleware       — check ownership ← here
priority 1: FeatureAccessMiddleware   — check feature plan
priority 0: QuotaMiddleware           — usage quotas

Ownership runs after permission checks — so a user must first pass #[RequiresPermission] (if present) before ownership is evaluated.

On this page