The system has two roles: super and admin. Users without a role are regular users.
Super exists separately from admin because someone needs to manage admins. Without it you end up with ad-hoc "is this the original admin?" checks. Super bypasses all policy checks entirely, admin goes through them.
A user holds one role at a time. No stacking. The Role enum in app/Enums/Role.php defines the values, and Spatie handles assignment and lookup.
Only two Spatie permissions exist: users.manage and users.assign-role. Both are assigned to the admin role. Super doesn't need permissions because its before() hook in the policy returns true unconditionally.
The interesting authorization lives in UserPolicy, not in the permission table. Permissions answer "can this user manage users at all?" and the policy answers "can this user manage this specific user?" The target matters: an admin can edit a regular user but can't touch a super user.
This avoids fragmenting the permission table with things like users.manage-non-super or users.demote-admin. Those encode business logic into permission names and don't scale.
The UserPolicy before() method gives super unconditional access. Everything below applies to admin only.
If admin needs to do something to another admin, super handles it.
is_password_reset_required is a boolean on the users table. When true, the EnsurePasswordUpdated middleware blocks all requests except PATCH /account/password and POST /auth/logout. The user is effectively locked to a single action until they change their password.
This flag gets set in two places: the initial super user seed (so the default initinit password is always temporary), and when an admin forces a password reset on a user through POST /admin/users/{id}/password-reset.
The forced reset generates a random password, emails it to the user, revokes all their tokens, and sets the flag. On next login they get the temp password, hit the middleware wall, and must change it.
No expiration on the temp password. The flag already locks the account. If the email is intercepted, the attacker can only change the password (which alerts the real user since they can no longer log in). Same threat model as a standard password reset link.
The app uses a single api guard backed by Sanctum. No web guard since there are no sessions. The User model sets $guard_name = 'api' so Spatie matches roles and permissions against the correct guard.
Roles and permissions are structural data. They're seeded from a migration (0002_01_01_000000_seed_roles_and_permissions) so they exist after php artisan migrate in any environment, including production. No need to remember to run a separate seeder.
The migration calls RoleAndPermissionSeeder which creates both roles, both permissions, assigns permissions to admin, and creates the initial super user with a temp password.
DevSeeder adds an admin and a regular user for local development. It runs from DatabaseSeeder and is gated behind an environment check.