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
39 changes: 36 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,12 @@ class AdminController extends Controller
// Get all roles
$roles = $this->roleService->getAllWithPermissions();

// Assign roles to user
// Add roles to user (keeps existing roles)
$this->authService->assignRolesToUser($user, ['admin', 'editor']);

// Or replace the user's roles with exactly this set
$this->authService->syncRolesForUser($user, ['editor']);

// Check if user has role
if ($this->authService->userHasRole($user, 'admin')) {
// User is admin
Expand Down Expand Up @@ -294,6 +297,11 @@ if (auth()->user()->isSuperAdmin()) {
}
```

> **Super-admins and role checks:** super-admins pass every *permission* check (`hasPermissionTo()`,
> `can()`, the `permission:` middleware) and the `role:` middleware, but `hasRole()` /
> `hasAnyRole()` / `hasAllRoles()` are literal. A super-admin only "has" the roles actually
> assigned to them. Use `isSuperAdmin()` or a permission check when you mean "may do anything".

> **Note:** `can()` and Laravel's `Gate` rely on Keystone registering permissions in `KeystoneServiceProvider::registerPermissionsWithGate()`, which is skipped when running in the console (e.g. `php artisan tinker`) to avoid registration during install/migration — it still runs normally during HTTP requests and the test suite. If you're debugging permissions in `tinker` and `can()` always returns `false`, this is why; use `hasPermissionTo()` / `hasRole()` directly instead, which don't depend on Gate registration.

#### Service Layer Approach (Recommended for Controllers)
Expand Down Expand Up @@ -327,7 +335,7 @@ curl -X GET http://localhost/api/roles \
-H "Accept: application/json"
```

**Assign Role to User:**
**Add a Role to a User** (keeps the user's other roles):

```bash
curl -X POST http://localhost/api/users/1/roles \
Expand All @@ -336,6 +344,18 @@ curl -X POST http://localhost/api/users/1/roles \
-d '{"roles": ["admin"]}'
```

**Replace a User's Roles** (`PUT`; send `[]` to remove all):

```bash
curl -X PUT http://localhost/api/users/1/roles \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"roles": ["editor"]}'
```

The same `POST` adds / `PUT` replaces pattern applies to `/api/users/{user}/permissions` and
`/api/roles/{role}/permissions`.

## Architecture

### Service Layer
Expand All @@ -347,7 +367,7 @@ All role and permission operations go through dedicated services:
- **PermissionService** - Permission CRUD and queries
- `getAllWithRoles()`, `create()`, `delete()`, `syncToUser()`
- **AuthorizationService** - High-level authorization operations
- `assignRolesToUser()`, `assignPermissionsToUser()`, `userHasRole()`, `userHasPermission()`
- `assignRolesToUser()`, `assignPermissionsToUser()` (add), `syncRolesForUser()`, `syncPermissionsForUser()` (replace), `userHasRole()`, `userHasPermission()`

All services are registered in Laravel's service container with interface bindings and
convenient aliases:
Expand Down Expand Up @@ -423,6 +443,19 @@ $manager = KeystoneRole::create([
]);
```

#### Tenant Query Scopes

Both `KeystoneRole` and `KeystonePermission` provide the same scopes. The automatic tenant
scope always applies, so a tenant user already sees their tenant's rows plus global rows. These
scopes narrow that further. They never widen it.

```php
KeystoneRole::global()->get(); // global only (tenant_id = NULL)
KeystoneRole::tenantSpecific()->get(); // tenant-owned only (tenant_id NOT NULL)
KeystoneRole::forTenant($tenantId)->get(); // that tenant's rows only — no globals
KeystoneRole::withoutTenant()->forTenant($otherId)->get(); // explicit cross-tenant read
```

#### Super-Admin Operations

```php
Expand Down
3 changes: 3 additions & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

/**
* @property string|null $tenant_id
*/
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
Expand Down
4 changes: 4 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
},
"require-dev": {
"fakerphp/faker": "^1.23",
"larastan/larastan": "^3.12",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
"laravel/sail": "^1.41",
Expand Down Expand Up @@ -67,6 +68,9 @@
"@php artisan config:clear --ansi",
"./vendor/bin/phpunit -c phpunit.single-tenant.xml"
],
"lint": "pint --test",
"lint:fix": "pint",
"analyze": "phpstan analyze",
"version": [
"@php -r \"echo 'Current version: '; passthru('git describe --tags --abbrev=0 2>/dev/null || echo No version tags yet');\""
],
Expand Down
53 changes: 53 additions & 0 deletions docs/multi-tenancy.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,56 @@ This means:
- You don't need to manually add `where('tenant_id', ...)` to every query
- The filtering happens automatically at the model level

### Tenant Query Scopes

Both models share these scopes. Each one narrows the query and combines with the automatic tenant
scope above; none of them removes it:

| Scope | Returns |
|---|---|
| `global()` | Rows with `tenant_id = NULL` |
| `tenantSpecific()` | Rows with a non-null `tenant_id` |
| `forTenant($tenantId)` | Rows with `tenant_id = $tenantId` only. Global rows are **not** included |
| `withoutTenant()` | Removes the automatic tenant scope (cross-tenant reads) |

Because the automatic scope still applies, a tenant A user calling `forTenant($tenantB)` gets no
rows. To read another tenant's roles or permissions, opt in explicitly:

```php
KeystonePermission::withoutTenant()->forTenant($tenantB)->get();
```

To get one tenant's rows **plus** globals for an arbitrary tenant:

```php
KeystonePermission::withoutTenant()
->where(fn ($q) => $q->where('tenant_id', $tenantId)->orWhereNull('tenant_id'))
->get();
```

### Name Resolution

When you pass a role or permission **name** (rather than a model) to an assignment method, Keystone looks it up in the tenant of whatever is *receiving* it, not in the logged-in caller's scope:

| Call | Name is resolved in |
|---|---|
| `$user->assignRole('manager')`, `removeRole`, `syncRoles`, `givePermissionTo`, `revokePermissionTo`, `syncPermissions` | the user's tenant |
| `$role->givePermissionTo('edit')`, `syncPermissions`, `revokePermissionTo` | the role's tenant |
| `$permission->assignRole('manager')`, `syncRoles`, `removeRole` | the permission's tenant |

Rules:

- The tenant's own row wins over a global row with the same name.
- If the receiver has no tenant, only global rows match.
- If nothing matches, a `ModelNotFoundException` is thrown and nothing changes.
- Model instances are always used exactly as given.

This means a global admin, console command, or queued job assigning `manager` to a tenant B user always gets tenant B's `manager`, even when other tenants have one too. You can use the same lookup directly:

```php
$role = KeystoneRole::findByNameForTenant('manager', $tenantId);
```

### Auto-Population on Creation

When creating roles or permissions, the `tenant_id` is automatically populated from the authenticated user:
Expand Down Expand Up @@ -363,6 +413,9 @@ if ($user->isSuperAdmin()) {
$tenantRoles = KeystoneRole::all();
}

// Role checks are literal, even for super-admins:
$user->hasRole('manager'); // true only if the user actually holds `manager`

// Or use the method directly
if ($user->canBypassPermissions()) {
// Same as isSuperAdmin()
Expand Down
9 changes: 9 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
includes:
- vendor/larastan/larastan/extension.neon

parameters:
level: 5
paths:
- src
- app

16 changes: 14 additions & 2 deletions routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,22 +46,34 @@
->middleware('permission:delete-permissions')
->name('api.permissions.destroy');

// Assign roles and permissions to users
// Assign (POST adds) or sync (PUT replaces) roles and permissions for users
Route::post('/users/{user}/roles', [RolePermissionController::class, 'assignRoles'])
->middleware('permission:assign-roles')
->name('api.users.roles.assign');

Route::put('/users/{user}/roles', [RolePermissionController::class, 'syncRoles'])
->middleware('permission:assign-roles')
->name('api.users.roles.sync');

Route::post('/users/{user}/permissions', [RolePermissionController::class, 'assignPermissions'])
->middleware('permission:assign-permissions')
->name('api.users.permissions.assign');

Route::put('/users/{user}/permissions', [RolePermissionController::class, 'syncPermissions'])
->middleware('permission:assign-permissions')
->name('api.users.permissions.sync');

// Get user roles and permissions
Route::get('/users/{user}/roles-permissions', [RolePermissionController::class, 'userRolesPermissions'])
->middleware('permission:view-users')
->name('api.users.roles-permissions');

// Assign permissions to roles
// Assign (POST adds) or sync (PUT replaces) permissions for roles
Route::post('/roles/{role}/permissions', [RolePermissionController::class, 'assignPermissionsToRole'])
->middleware('permission:assign-permissions')
->name('api.roles.permissions.assign');

Route::put('/roles/{role}/permissions', [RolePermissionController::class, 'syncRolePermissions'])
->middleware('permission:assign-permissions')
->name('api.roles.permissions.sync');
});
2 changes: 1 addition & 1 deletion src/Console/Commands/AssignPermissionCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ protected function assignToUser(string $userIdentifier, array $permissions): int
*/
protected function gatherPermissions(): array
{
$permissions = $this->argument('permission') ?? [];
$permissions = $this->argument('permission');

// From -P / --permission options (repeatable)
if ($permissionOptions = $this->option('permission')) {
Expand Down
4 changes: 2 additions & 2 deletions src/Console/Commands/AssignRoleCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public function handle(): int
$action = 'removed';
} elseif ($this->option('sync')) {
// Replace all roles
$this->authorizationService->assignRolesToUser($user, $roles);
$this->authorizationService->syncRolesForUser($user, $roles);
$action = 'synced';
} else {
// Add roles (default behavior)
Expand Down Expand Up @@ -97,7 +97,7 @@ public function handle(): int
*/
protected function gatherRoles(): array
{
$roles = $this->argument('role') ?? [];
$roles = $this->argument('role');

// From -R / --role options (repeatable)
if ($roleOptions = $this->option('role')) {
Expand Down
3 changes: 2 additions & 1 deletion src/Console/Commands/Concerns/InteractsWithKeystone.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ protected function getDefaultGuard(): string
*/
protected function resolveGuard(): ?string
{
$guard = $this->option('guard');
// Not every command using this trait defines --guard
$guard = $this->hasOption('guard') ? $this->input->getOption('guard') : null;

if ($guard && ! array_key_exists($guard, config('auth.guards'))) {
$this->error("Guard [{$guard}] is not defined in your auth configuration.");
Expand Down
6 changes: 3 additions & 3 deletions src/Console/Commands/UnassignPermissionCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ protected function removeFromRole(string $roleName): int
[
['Role', $role->name],
['Guard', $role->guard_name],
['Previous Permissions', implode(', ', $previousPermissions) ?: '(none)'],
['Previous Permissions', implode(', ', $previousPermissions)],
['Removed Permissions', implode(', ', $permissions)],
['Current Permissions', implode(', ', $currentPermissions) ?: '(none)'],
]
Expand Down Expand Up @@ -171,7 +171,7 @@ protected function removeFromUser(string $userIdentifier): int
['Property', 'Value'],
[
['User', $user->email],
['Previous Direct Permissions', implode(', ', $previousPermissions) ?: '(none)'],
['Previous Direct Permissions', implode(', ', $previousPermissions)],
['Removed Permissions', implode(', ', $permissions)],
['Current Direct Permissions', implode(', ', $currentPermissions) ?: '(none)'],
['All Permissions (incl. via roles)', count($allPermissions).' total'],
Expand All @@ -191,7 +191,7 @@ protected function removeFromUser(string $userIdentifier): int
*/
protected function gatherPermissions(): array
{
$permissions = $this->argument('permission') ?? [];
$permissions = $this->argument('permission');

// From -P / --permission options (repeatable)
if ($permissionOptions = $this->option('permission')) {
Expand Down
4 changes: 2 additions & 2 deletions src/Console/Commands/UnassignRoleCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ public function handle(): int
['Property', 'Value'],
[
['User', $user->email],
['Previous Roles', implode(', ', $previousRoles) ?: '(none)'],
['Previous Roles', implode(', ', $previousRoles)],
['Removed Roles', implode(', ', $roles)],
['Current Roles', implode(', ', $currentRoles) ?: '(none)'],
]
Expand All @@ -89,7 +89,7 @@ public function handle(): int
*/
protected function gatherRoles(): array
{
$roles = $this->argument('role') ?? [];
$roles = $this->argument('role');

// From -R / --role options (repeatable)
if ($roleOptions = $this->option('role')) {
Expand Down
Loading
Loading