Skip to content

Commit cad934f

Browse files
committed
feat: Policies
1 parent 484e1e4 commit cad934f

File tree

9 files changed

+226
-22
lines changed

9 files changed

+226
-22
lines changed

README.md

+33
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ This Laravel package aims to store and manage user settings/preferences in a sim
2222
* [Available Casts](#available-casts)
2323
* [Custom Caster](#custom-caster)
2424
* [Custom Rules](#custom-rules)
25+
* [Policies](#policies)
2526
* [Routing](#routing)
2627
* [Anantomy](#anantomy)
2728
* [Example](#example)
@@ -235,6 +236,9 @@ Signature:
235236
- $user the logged in user
236237
- PolicyAction enum: the action the user wants to perform index/get/update/delete
237238

239+
> this is just the bare minimum regarding Authorization.
240+
> For more fine-grained authorization checks refer to [Policies](#policies)
241+
238242
#### Example implementation:
239243

240244
```php
@@ -375,6 +379,35 @@ class MyRule implements ValidationRule
375379
->withRule(new MyRule("Europe","Asia"))
376380
```
377381

382+
## Policies
383+
384+
each preference can have a Policy, should [isUserAuthorized](#isuserauthorized) not be enough for your usecase
385+
386+
### Creating policies
387+
388+
implement `PreferencePolicy` and the 4 methods defined by the contract
389+
390+
| parameter | description |
391+
|-----------------------------|------------------------------------------------------------|
392+
| Authenticatable $user | the currently logged in user, if any |
393+
| PreferenceableModel $model | the model on which you are trying to modify the preference |
394+
| PreferenceGroup $preference | the preference enum in question |
395+
396+
### Adding policies
397+
398+
````php
399+
PreferenceBuilder::init(Preferences::LANGUAGE)
400+
->withPolicy(new MyPolicy())
401+
->updateOrCreate()
402+
403+
404+
PreferenceBuilder::initBulk([
405+
'name' => Preferences::LANGUAGE,
406+
'policy' => new MyPolicy()
407+
]);
408+
409+
````
410+
378411
## Routing
379412

380413
off by default, enable it in the config

database/migrations/2024_04_14_10000_switch_from_json_to_text.php

+6-6
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ public function up()
1212
$preferenceTable = (new Preference())->getTable();
1313

1414
Schema::table($preferenceTable, function (Blueprint $table) {
15-
$table->text('policy')->change();
16-
$table->text('cast')->change();
17-
$table->text('rule')->change();
15+
$table->text('policy')->nullable()->change();
16+
$table->text('cast')->nullable()->change();
17+
$table->text('rule')->nullable()->change();
1818
});
1919
}
2020

@@ -23,9 +23,9 @@ public function down()
2323
$preferenceTable = (new Preference())->getTable();
2424

2525
Schema::table($preferenceTable, function (Blueprint $table) {
26-
$table->json('policy')->change();
27-
$table->json('cast')->change();
28-
$table->json('rule')->change();
26+
$table->json('policy')->nullable()->change();
27+
$table->json('cast')->nullable()->change();
28+
$table->json('rule')->nullable()->change();
2929
});
3030
}
3131
};

src/Contracts/PreferencePolicy.php

+4-4
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@
77

88
interface PreferencePolicy
99
{
10-
public function index(Authenticatable $user, string $preferences): bool;
10+
public function index(?Authenticatable $user, PreferenceableModel $model, PreferenceGroup $preference): bool;
1111

12-
public function get(Authenticatable $user, Preference $preference, mixed $value): bool;
12+
public function get(?Authenticatable $user, PreferenceableModel $model, PreferenceGroup $preference): bool;
1313

14-
public function update(Authenticatable $user, Preference $preference, mixed $value): bool;
14+
public function update(?Authenticatable $user, PreferenceableModel $model, PreferenceGroup $preference): bool;
1515

16-
public function delete(Authenticatable $user, Preference $preference, mixed $value): bool;
16+
public function delete(?Authenticatable $user, PreferenceableModel $model, PreferenceGroup $preference): bool;
1717
}

src/Traits/HasPreferences.php

+31-9
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Illuminate\Support\Facades\Auth;
99
use Illuminate\Validation\ValidationException;
1010
use Matteoc99\LaravelPreference\Contracts\PreferenceGroup;
11+
use Matteoc99\LaravelPreference\Contracts\PreferencePolicy;
1112
use Matteoc99\LaravelPreference\Enums\PolicyAction;
1213
use Matteoc99\LaravelPreference\Exceptions\PreferenceNotFoundException;
1314
use Matteoc99\LaravelPreference\Models\Preference;
@@ -40,9 +41,8 @@ private function userPreferences(): MorphMany
4041
public function getPreference(PreferenceGroup|Preference $preference, mixed $default = null): mixed
4142
{
4243

43-
$this->authorize(PolicyAction::GET);
4444

45-
$preference = $this->validateAndRetrievePreference($preference);
45+
$preference = $this->validateAndRetrievePreference($preference, PolicyAction::GET);
4646

4747
$userPreference = $this->userPreferences()->where('preference_id', $preference->id)->first();
4848

@@ -65,9 +65,8 @@ public function getPreference(PreferenceGroup|Preference $preference, mixed $def
6565
*/
6666
public function setPreference(PreferenceGroup|Preference $preference, mixed $value): void
6767
{
68-
$this->authorize(PolicyAction::UPDATE);
6968

70-
$preference = $this->validateAndRetrievePreference($preference);
69+
$preference = $this->validateAndRetrievePreference($preference, PolicyAction::UPDATE);
7170

7271
ValidationHelper::validateValue(
7372
$value,
@@ -90,9 +89,8 @@ public function setPreference(PreferenceGroup|Preference $preference, mixed $val
9089
*/
9190
public function removePreference(PreferenceGroup|Preference $preference): int
9291
{
93-
$this->authorize(PolicyAction::DELETE);
9492

95-
$preference = $this->validateAndRetrievePreference($preference);
93+
$preference = $this->validateAndRetrievePreference($preference, PolicyAction::DELETE);
9694

9795

9896
return $this->userPreferences()->where('preference_id', $preference->id)->delete();
@@ -123,13 +121,17 @@ public function getPreferences(string $group = null): Collection
123121
* Validate existence of a preference and retrieve it.
124122
*
125123
* @param PreferenceGroup|Preference $preference Preference name.
124+
* @param PolicyAction $action
126125
*
127126
* @return Preference
128-
* @throws PreferenceNotFoundException If preference not found.
127+
* @throws AuthorizationException
128+
* @throws PreferenceNotFoundException
129129
*/
130-
private function validateAndRetrievePreference(PreferenceGroup|Preference $preference): Preference
130+
private function validateAndRetrievePreference(PreferenceGroup|Preference $preference, PolicyAction $action): Preference
131131
{
132132

133+
$this->authorize($action);
134+
133135
if (!$preference instanceof Preference) {
134136

135137
SerializeHelper::conformNameAndGroup($preference, $group);
@@ -141,7 +143,27 @@ private function validateAndRetrievePreference(PreferenceGroup|Preference $prefe
141143
throw new PreferenceNotFoundException("Preference not found: $preference in group $group");
142144
}
143145

144-
//Todo Gate
146+
if (!empty($preference->policy)) {
147+
$policy = $preference->policy;
148+
$authorized = false;
149+
150+
$enum = SerializeHelper::reversePreferenceToEnum($preference);
151+
152+
if ($policy instanceof PreferencePolicy) {
153+
$authorized = match ($action) {
154+
PolicyAction::INDEX => $policy->index(Auth::user(), $this, $enum),
155+
PolicyAction::GET => $policy->get(Auth::user(), $this, $enum),
156+
PolicyAction::UPDATE => $policy->update(Auth::user(), $this, $enum),
157+
PolicyAction::DELETE => $policy->delete(Auth::user(), $this, $enum),
158+
default => throw new AuthorizationException("Unknown Policy: " . $action->name),
159+
};
160+
}
161+
162+
if (!$authorized) {
163+
throw new AuthorizationException("The user is not authorized to perform the action: " . $action->name);
164+
}
165+
166+
}
145167

146168
return $preference;
147169
}

src/Utils/SerializeHelper.php

+34
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use BackedEnum;
66
use InvalidArgumentException;
77
use Matteoc99\LaravelPreference\Contracts\PreferenceGroup;
8+
use Matteoc99\LaravelPreference\Models\Preference;
89
use RuntimeException;
910

1011
class SerializeHelper
@@ -16,6 +17,14 @@ public static function enumToString(PreferenceGroup|string $value): string
1617
return $value->value;
1718
}
1819

20+
/**
21+
* splits a preference enum into name and group as string
22+
*
23+
* @param PreferenceGroup|string $name
24+
* @param string|null $group
25+
*
26+
* @return void
27+
*/
1928
public static function conformNameAndGroup(PreferenceGroup|string &$name, string|null &$group): void
2029
{
2130
//auto set group scope for enums
@@ -29,4 +38,29 @@ public static function conformNameAndGroup(PreferenceGroup|string &$name, string
2938

3039
$name = SerializeHelper::enumToString($name);
3140
}
41+
42+
/**
43+
* inverse of the above, reconstructs the original enum
44+
*
45+
* @param Preference $preference
46+
*
47+
* @return PreferenceGroup
48+
*/
49+
public static function reversePreferenceToEnum(Preference $preference): PreferenceGroup
50+
{
51+
52+
$enumClass = $preference->group;
53+
$enumValue = $preference->name;
54+
55+
if (!class_exists($enumClass)) {
56+
throw new InvalidArgumentException("Enum class $enumClass does not exist.");
57+
}
58+
59+
if (!in_array(BackedEnum::class, class_implements($enumClass))) {
60+
throw new InvalidArgumentException("Enum class $enumClass must be a backed enum.");
61+
}
62+
63+
return $enumClass::tryFrom($enumValue);
64+
65+
}
3266
}

tests/PolicyTest.php

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
namespace Matteoc99\LaravelPreference\Tests;
4+
5+
use Illuminate\Auth\Access\AuthorizationException;
6+
use Illuminate\Support\Facades\Auth;
7+
use Matteoc99\LaravelPreference\Factory\PreferenceBuilder;
8+
use Matteoc99\LaravelPreference\Tests\TestSubjects\Enums\General;
9+
use Matteoc99\LaravelPreference\Tests\TestSubjects\Policies\MyPolicy;
10+
11+
class PolicyTest extends TestCase
12+
{
13+
14+
private MyPolicy $policy;
15+
16+
public function setUp(): void
17+
{
18+
parent::setUp();
19+
20+
$this->policy = new MyPolicy();
21+
PreferenceBuilder::init(General::LANGUAGE)->withPolicy($this->policy)->nullable()->create();
22+
}
23+
24+
/** @test */
25+
public function no_user_fails_auth()
26+
{
27+
Auth::logout();
28+
$this->expectException(AuthorizationException::class);
29+
$this->testUser->setPreference(General::LANGUAGE, "it");
30+
}
31+
32+
/** @test */
33+
public function test_user_can_set_and_get_preference()
34+
{
35+
Auth::login($this->testUser);
36+
$this->testUser->setPreference(General::LANGUAGE, "it");
37+
38+
$this->assertEquals('it', $this->testUser->getPreference(General::LANGUAGE));
39+
}
40+
41+
/** @test */
42+
public function test_user_can_not_set_and_get_admin()
43+
{
44+
Auth::login($this->testUser);
45+
$this->expectException(AuthorizationException::class);
46+
$this->adminUser->setPreference(General::LANGUAGE, "it");
47+
}
48+
49+
/** @test */
50+
public function noone_can_delete()
51+
{
52+
$this->expectException(AuthorizationException::class);
53+
$this->adminUser->removePreference(General::LANGUAGE,);
54+
}
55+
56+
}

tests/TestCase.php

+8-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
use Illuminate\Foundation\Application;
88
use Illuminate\Foundation\Testing\RefreshDatabase;
99
use Illuminate\Support\Facades\Auth;
10-
use Illuminate\Support\Facades\Schema;
1110
use Matteoc99\LaravelPreference\PreferenceServiceProvider;
1211
use Matteoc99\LaravelPreference\Tests\TestSubjects\Models\User;
1312

@@ -17,6 +16,7 @@ class TestCase extends \Orchestra\Testbench\TestCase
1716

1817
protected User $testUser;
1918
protected User $otherUser;
19+
protected User $adminUser;
2020

2121
public function setUp(): void
2222
{
@@ -35,8 +35,14 @@ public function setUp(): void
3535
$this->testUser = new User([
3636
'email' => '[email protected]'
3737
]);
38+
39+
$this->adminUser = new User([
40+
'email' => '[email protected]',
41+
'admin' => true,
42+
]);
3843
$this->testUser->save();
3944
$this->otherUser->save();
45+
$this->adminUser->save();
4046

4147
Auth::login($this->testUser);
4248
}
@@ -90,6 +96,7 @@ protected function setUpDatabase()
9096
$this->getSchema()->create('users', function (Blueprint $table) {
9197
$table->increments('id');
9298
$table->string('email');
99+
$table->boolean('admin')->default(false);
93100
$table->softDeletes();
94101
$table->timestamps();
95102
});

tests/TestSubjects/Models/User.php

+2-2
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ class User extends \Illuminate\Foundation\Auth\User implements PreferenceableMod
1111
{
1212
use HasPreferences;
1313

14-
protected $fillable = ['email'];
14+
protected $fillable = ['email', "admin"];
1515

1616
public function isUserAuthorized(?Authenticatable $user, PolicyAction $action): bool
1717
{
18-
return $user?->id == $this->id ;
18+
return $user?->id == $this->id;
1919
}
2020
}

0 commit comments

Comments
 (0)