Protecting actions with attributes

Who this is for

Ada can browse product.list without an account. She should not be able to invent catalog items or place someone else’s order. You want those rules next to the method — not buried in copy-pasted mustAuthenticate() calls.

What you will learn

  • Lock one action or a whole service
  • Leave login / register public with except
  • Choose #[Can] (all permissions) vs #[CanAny] (any one)
  • When a rule needs the order row itself (write a few lines of PHP)

Before you start

Before you start

How it fits together

flowchart TD
  Req["POST product.create + Bearer"] --> Jwt[JwtAuthentication]
  Jwt -->|no user| E401[401]
  Jwt -->|user ok| Attr{"#[Authenticated] / #[Can]?"}
  Attr -->|missing permission| E403[403]
  Attr -->|ok| Body[createAction]

Pionia runs attributes before validation and before your method body. Anonymous callers never reach table('products')->save().

The three attributes

AttributePlain English
#[Authenticated]Must be logged in
#[Can('product.manage')]Logged in and has this permission
#[Can(['a', 'b'])]Needs every permission listed
#[CanAny(['a', 'b'])]Needs any one permission

Put permission slugs in the JWT when Ada logs in (permissions / perms). That is what #[Can] compares against.

Scenario: public catalog, private create

use Pionia\Auth\Attributes\Authenticated;

class ProductService extends Service
{
    // Anyone can browse
    protected function listAction(Arrayable $data): ApiResponse
    {
        return response(0, 'OK', ['products' => table('products')->all()]);
    }

    // Only signed-in staff/customers with access
    #[Authenticated]
    protected function createAction(Arrayable $data): ApiResponse
    {
        // save product …
    }
}

Try create without a token → 401. With Ada’s token → success.

Scenario: whole CustomerService, login stays open

use Pionia\Auth\Attributes\Authenticated;
use Pionia\Auth\Attributes\Can;

#[Authenticated(except: ['login', 'register'])]
class CustomerService extends Service
{
    protected function loginAction(Arrayable $data): ApiResponse { /* public */ }

    protected function meAction(Arrayable $data): ApiResponse
    {
        return response(0, 'OK', ['user' => $this->auth()->user]);
    }

    #[Can('customer.admin')]
    protected function deactivateAction(Arrayable $data): ApiResponse { /* … */ }
}

except accepts the action name clients send (login) or loginAction.

Scenario: checkout permissions

use Pionia\Auth\Attributes\Can;
use Pionia\Auth\Attributes\CanAny;

#[Can('order.place')]
protected function placeAction(Arrayable $data): ApiResponse { /* … */ }

#[CanAny(['order.refund', 'admin'])]
protected function refundAction(Arrayable $data): ApiResponse { /* … */ }

When attributes are not enough

“Only the customer who owns this order may cancel it” needs the row:

#[Authenticated]
protected function cancelAction(Arrayable $data): ApiResponse
{
    $order = table('orders')->get($data->getInt('id'));
    if ((int) $order->customer_id !== (int) $this->auth()->user->id) {
        return response(403, 'You can only cancel your own orders');
    }
    // …
}

Try it yourself

Try it yourself
  1. Put #[Authenticated] on product.create and confirm anonymous curls fail.
  2. Wrap CustomerService with except: ['login'] and confirm login still works.
  3. Issue a token without permissions, then hit #[Can('product.manage')] — expect 403.

Common mistakes

  • Protecting login by accident
  • Expecting #[Can] to invent permissions not present on the user
  • Mixing #[Can(['a','b'])] (all) with #[CanAny] (any)

What’s next

JWT authentication

Put permissions in the token.

Tutorial Step 9

Wire login into Pionia Shop.