diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0139398..3180773 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,18 +2,33 @@ name: CI on: pull_request: + paths-ignore: + - '**/*.md' + - 'docs/**' + - 'LICENSE' + +env: + COMPOSER_PROCESS_TIMEOUT: 0 + COMPOSER_NO_INTERACTION: 1 + COMPOSER_NO_AUDIT: 1 jobs: lint: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: shivammathur/setup-php@v2 with: php-version: '8.4' + - uses: actions/cache@v5 + with: + path: vendor + key: composer-lint-${{ hashFiles('composer.json') }} + restore-keys: composer-lint- + - run: composer install --no-interaction - run: vendor/bin/pint --test @@ -23,12 +38,18 @@ jobs: needs: lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: shivammathur/setup-php@v2 with: php-version: '8.4' + - uses: actions/cache@v5 + with: + path: vendor + key: composer-static-${{ hashFiles('composer.json') }} + restore-keys: composer-static- + - run: composer install --no-interaction - run: vendor/bin/phpstan analyse @@ -43,7 +64,7 @@ jobs: php: ['8.4', '8.5'] laravel: ['12', '13'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: shivammathur/setup-php@v2 with: @@ -51,14 +72,20 @@ jobs: coverage: pcov extensions: redis + - uses: actions/cache@v5 + with: + path: vendor + key: composer-test-${{ matrix.php }}-${{ matrix.laravel }}-${{ hashFiles('composer.json') }} + restore-keys: composer-test-${{ matrix.php }}-${{ matrix.laravel }}- + - run: composer require illuminate/support:^${{ matrix.laravel }}.0 --no-update - run: composer update --no-interaction - - run: docker compose -f docker-compose.test.yml up -d --wait valkey + - run: docker compose up -d --wait valkey - - run: vendor/bin/pest --coverage --min=90 + - run: vendor/bin/pest --exclude-group=benchmark --coverage --min=90 - - run: docker compose -f docker-compose.test.yml down -v + - run: docker compose down -v test-database: name: Tests (${{ matrix.database }} / PHP ${{ matrix.php }} / Laravel ${{ matrix.laravel }}) @@ -69,20 +96,26 @@ jobs: matrix: php: ['8.4', '8.5'] laravel: ['12', '13'] - database: [pgsql, mysql] + database: [pgsql] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: redis, pdo_pgsql, pdo_mysql + extensions: redis, pdo_pgsql + + - uses: actions/cache@v5 + with: + path: vendor + key: composer-db-${{ matrix.php }}-${{ matrix.laravel }}-${{ hashFiles('composer.json') }} + restore-keys: composer-db-${{ matrix.php }}-${{ matrix.laravel }}- - run: composer require illuminate/support:^${{ matrix.laravel }}.0 --no-update - run: composer update --no-interaction - - run: docker compose -f docker-compose.test.yml up -d --wait ${{ matrix.database }} valkey + - run: docker compose up -d --wait postgres valkey - - run: DB_CONNECTION=${{ matrix.database }} vendor/bin/pest + - run: DB_CONNECTION=${{ matrix.database }} vendor/bin/pest --exclude-group=benchmark - - run: docker compose -f docker-compose.test.yml down -v + - run: docker compose down -v diff --git a/.gitignore b/.gitignore index 3d2e6b6..f7514c4 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,5 @@ composer.lock .claude/ CLAUDE.md /backlog -/docs/superpowers \ No newline at end of file +/docs/superpowers +.worktrees/ \ No newline at end of file diff --git a/README.md b/README.md index 4b7ac9e..632e6e8 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # Policy Engine for Laravel +[![Pint](https://img.shields.io/github/actions/workflow/status/dynamik-dev/laravel-policy-engine/ci.yml?branch=main&label=Pint&logo=laravel)](https://github.com/dynamik-dev/laravel-policy-engine/actions/workflows/ci.yml) +[![Larastan](https://img.shields.io/github/actions/workflow/status/dynamik-dev/laravel-policy-engine/ci.yml?branch=main&label=Larastan&logo=php)](https://github.com/dynamik-dev/laravel-policy-engine/actions/workflows/ci.yml) +[![Pest](https://img.shields.io/github/actions/workflow/status/dynamik-dev/laravel-policy-engine/ci.yml?branch=main&label=Pest&logo=php)](https://github.com/dynamik-dev/laravel-policy-engine/actions/workflows/ci.yml) + An IAM-style policy engine for Laravel. Define your authorization as declarative JSON documents and import them the way you'd manage AWS IAM policies. ```json @@ -106,16 +110,15 @@ Every component is coded to a PHP interface. Swap any implementation via the ser ## Requirements -| Dependency | Supported Versions | -|---|---| -| PHP | 8.4, 8.5 | -| Laravel | 12, 13 | -| PostgreSQL | 17+ | -| MySQL | 8.4+ | -| SQLite | 3.35+ | -| Valkey / Redis | 8+ | +| Dependency | Supported Versions | +| -------------- | ------------------ | +| PHP | 8.4, 8.5 | +| Laravel | 12, 13 | +| PostgreSQL | 17+ | +| SQLite | 3.35+ | +| Valkey / Redis | 8+ | -SQLite works out of the box for development. PostgreSQL, MySQL, and Valkey are optional — the package tests against all of them in CI. +SQLite works out of the box for development. PostgreSQL and Valkey are optional — the package tests against both in CI. MySQL is not officially supported but should work fine since Laravel's query builder abstracts the differences. ## Documentation diff --git a/composer.json b/composer.json index 62c04b8..28f2a0b 100644 --- a/composer.json +++ b/composer.json @@ -23,9 +23,9 @@ "laravel/pint": "^1.27", "laravel/sanctum": "^4.3", "orchestra/testbench": "^10.0|^11.0", - "pestphp/pest": "^3.0", - "pestphp/pest-plugin-laravel": "^3.0", - "pestphp/pest-plugin-mutate": "^3.0" + "pestphp/pest": "^4.0", + "pestphp/pest-plugin-laravel": "^4.0", + "pestphp/pest-plugin-mutate": "^4.0" }, "autoload": { "psr-4": { diff --git a/config/policy-engine.php b/config/policy-engine.php index 2f5d872..c0f9db5 100644 --- a/config/policy-engine.php +++ b/config/policy-engine.php @@ -2,44 +2,32 @@ declare(strict_types=1); +use DynamikDev\PolicyEngine\Resolvers\BoundaryPolicyResolver; +use DynamikDev\PolicyEngine\Resolvers\IdentityPolicyResolver; +use DynamikDev\PolicyEngine\Resolvers\ResourcePolicyResolver; +use DynamikDev\PolicyEngine\Resolvers\SanctumPolicyResolver; + return [ 'cache' => [ 'enabled' => true, - - // WARNING: If your cache driver does not support tags (file, database), - // policy-engine uses generation counters for invalidation. Entries expire - // naturally via TTL rather than being deleted immediately. For instant - // invalidation, use a tagged driver (Redis, Memcached). 'store' => env('POLICY_ENGINE_CACHE_STORE', 'default'), - - // TTL in seconds. - // Lower values reduce the window for stale cached permissions after revocation. 'ttl' => 60 * 5, ], 'protect_system_roles' => true, 'log_denials' => true, - 'explain' => env('POLICY_ENGINE_EXPLAIN', false), + 'trace' => env('POLICY_ENGINE_TRACE', false), 'deny_unbounded_scopes' => false, - - // When true, boundary checks are applied even for unscoped (global) - // evaluations. By default, global assignments are inherently unbounded — - // boundaries only restrict scoped checks. Enable this if you need to - // enforce boundary ceilings on global permission checks. Be aware that - // this requires boundaries to exist for every scope the subject might - // access, which may cause unexpected denials. 'enforce_boundaries_on_global' => false, - - // Prefix prepended to all policy-engine table names. Useful when another - // package (e.g. Spatie Permission, Bouncer) already uses generic names - // like "permissions" or "roles". Set to 'pe_' for new installs alongside - // other permission packages. Empty string preserves backwards compatibility. 'table_prefix' => '', - - // The seeder class used by the policy-engine:sync command. - // Must be resolvable by db:seed --class (typically in Database\Seeders namespace). 'seeder_class' => 'PermissionSeeder', - 'document_path' => null, 'gate_passthrough' => [], 'import_subject_types' => [], + + 'resolvers' => [ + IdentityPolicyResolver::class, + BoundaryPolicyResolver::class, + ResourcePolicyResolver::class, + SanctumPolicyResolver::class, + ], ]; diff --git a/database/migrations/0009_create_resource_policies_table.php b/database/migrations/0009_create_resource_policies_table.php new file mode 100644 index 0000000..2b1580f --- /dev/null +++ b/database/migrations/0009_create_resource_policies_table.php @@ -0,0 +1,33 @@ +ulid('id')->primary(); + $table->string('resource_type'); + $table->string('resource_id')->nullable(); + $table->string('effect'); + $table->string('action'); + $table->string('principal_pattern')->nullable(); + $table->json('conditions')->nullable(); + $table->timestamps(); + + $table->index(['resource_type', 'resource_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists(config('policy-engine.table_prefix', '').'resource_policies'); + } +}; diff --git a/docker-compose.test.yml b/docker-compose.test.yml deleted file mode 100644 index d394448..0000000 --- a/docker-compose.test.yml +++ /dev/null @@ -1,39 +0,0 @@ -services: - valkey: - image: valkey/valkey:8-alpine - ports: - - "6379:6379" - healthcheck: - test: ["CMD", "valkey-cli", "ping"] - interval: 2s - timeout: 3s - retries: 5 - - postgres: - image: postgres:17-alpine - ports: - - "5432:5432" - environment: - POSTGRES_DB: policy_engine_test - POSTGRES_USER: test - POSTGRES_PASSWORD: test - healthcheck: - test: ["CMD-SHELL", "pg_isready -U test"] - interval: 2s - timeout: 3s - retries: 5 - - mysql: - image: mysql:8.4 - ports: - - "3306:3306" - environment: - MYSQL_DATABASE: policy_engine_test - MYSQL_USER: test - MYSQL_PASSWORD: test - MYSQL_ROOT_PASSWORD: root - healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] - interval: 2s - timeout: 3s - retries: 10 diff --git a/docker-compose.yml b/docker-compose.yml index bb81c36..d99deca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,24 @@ services: - test: - build: - context: . - dockerfile: docker/Dockerfile - command: vendor/bin/pest --coverage + valkey: + image: valkey/valkey:8-alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + interval: 2s + timeout: 3s + retries: 5 + + postgres: + image: postgres:17-alpine + ports: + - "5432:5432" + environment: + POSTGRES_DB: policy_engine_test + POSTGRES_USER: test + POSTGRES_PASSWORD: test + healthcheck: + test: ["CMD-SHELL", "pg_isready -U test"] + interval: 2s + timeout: 3s + retries: 5 diff --git a/docker/Dockerfile b/docker/Dockerfile deleted file mode 100644 index 9a96183..0000000 --- a/docker/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -FROM php:8.5-cli - -RUN apt-get update && apt-get install -y \ - git unzip libzip-dev libsqlite3-dev \ - && docker-php-ext-install zip pdo pdo_sqlite \ - && pecl install pcov && docker-php-ext-enable pcov \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -COPY --from=composer:latest /usr/bin/composer /usr/bin/composer - -WORKDIR /app - -COPY composer.json composer.lock ./ -RUN composer install --no-interaction -COPY . . diff --git a/docs/authorization/checking-permissions.md b/docs/authorization/checking-permissions.md index 36719a8..b9fa538 100644 --- a/docs/authorization/checking-permissions.md +++ b/docs/authorization/checking-permissions.md @@ -70,6 +70,28 @@ $user->cannotDo('posts.delete'); Use `canDo()` inside [model policies](../integrations/integrating-with-model-policies.md#writing-a-policy-that-uses-cando), where the policy method needs to check permissions without re-entering the Gate. +### Passing a resource to canDo + +```php +use DynamikDev\PolicyEngine\DTOs\Resource; + +$resource = new Resource(type: 'post', id: $post->id); +$user->canDo('posts.update', scope: $group, resource: $resource); +``` + +The `resource` parameter makes the evaluation request available to `ResourcePolicyResolver` and any conditions that inspect resource attributes. Models that use the `HasResourcePolicies` trait can convert themselves with `$post->toPolicyResource()`. + +### Passing environment data to canDo + +```php +$user->canDo('posts.publish', scope: $group, environment: [ + 'ip' => request()->ip(), + 'time' => now()->toIso8601String(), +]); +``` + +The `environment` array is forwarded to the evaluation context. Condition evaluators (like `ip_range` or `time_between`) read from it to make runtime decisions. + ## Listing a user's effective permissions ```php @@ -117,18 +139,18 @@ $assignments = $user->assignmentsFor(scope: $group); ## Debugging a permission decision ```php -$trace = $user->explain('posts.delete', scope: $group); +$result = $user->explain('posts.delete', scope: $group); ``` -Returns an `EvaluationTrace` with the full decision path: which assignments were found, which permissions were checked, whether a boundary blocked it, and the final result. +Returns an `EvaluationResult` with the decision, the resolver that decided it, any matched policy statements, and a trace log. ```php -$trace->subject; // "App\Models\User:42" -$trace->required; // "posts.delete" -$trace->result; // EvaluationResult::Allow or EvaluationResult::Deny -$trace->assignments; // array of role/scope/permissions_checked -$trace->boundary; // null or boundary note (e.g., "Passed boundary on scope [group::5]") -$trace->cacheHit; // bool +$result->decision; // Decision::Allow or Decision::Deny +$result->decidedBy; // "role:admin", "boundary:group::5", etc. +$result->matchedStatements; // array of PolicyStatement objects +$result->trace; // array of string trace entries ``` -> `explain()` is disabled by default. Set `POLICY_ENGINE_EXPLAIN=true` in your `.env` or `config('policy-engine.explain', true)` to enable it. Calling `explain()` when disabled throws a `RuntimeException`. +Each entry in `matchedStatements` is a `PolicyStatement` with `effect`, `action`, `source`, and optional `conditions`. The `trace` array contains human-readable strings describing each step of the evaluation. + +> The `matchedStatements` and `trace` arrays are only populated when the `trace` config key is `true`. Set `POLICY_ENGINE_TRACE=true` in your `.env` or `config('policy-engine.trace', true)` to enable it. When disabled, `explain()` still returns the `decision` and `decidedBy` fields, but the arrays are empty. diff --git a/docs/authorization/setting-permission-boundaries.md b/docs/authorization/setting-permission-boundaries.md index 7b3b44d..a0dc586 100644 --- a/docs/authorization/setting-permission-boundaries.md +++ b/docs/authorization/setting-permission-boundaries.md @@ -17,12 +17,14 @@ This scope now has a ceiling. Only permissions matching `posts.*` or `comments.* ## How boundary enforcement works -The evaluator checks boundaries before processing allow/deny rules. The order is: +Boundaries are enforced by the `BoundaryPolicyResolver` in the resolver chain. When evaluating a permission check, the resolver emits Deny statements for any registered permission that falls outside the scope's boundary ceiling. The evaluator then processes these alongside Allow/Deny statements from other resolvers. -1. Find the user's assignments (global + scoped) -2. Check the scope's boundary (if one exists) -3. If the required permission is **not** covered by any boundary pattern, deny immediately -4. If the boundary passes, proceed to normal deny/allow evaluation +The effective order is: + +1. The `IdentityPolicyResolver` finds the user's assignments and emits Allow/Deny statements from roles +2. The `BoundaryPolicyResolver` checks the scope's boundary (if one exists) +3. If the required permission is **not** covered by any boundary pattern, a Deny statement is emitted +4. Deny wins — the evaluator merges all statements and denies if any Deny matches ```php PolicyEngine::boundary('org::acme', ['posts.*', 'comments.*']); diff --git a/docs/authorization/using-conditions.md b/docs/authorization/using-conditions.md new file mode 100644 index 0000000..f327d13 --- /dev/null +++ b/docs/authorization/using-conditions.md @@ -0,0 +1,312 @@ +# Using Conditions + +Conditions add runtime checks to permissions. A permission with conditions only applies when every condition passes. Use them when static role-based rules are not enough -- for example, restricting edits to users in the same department as the resource, or limiting access to business hours. + +## Adding conditions to role permissions + +Attach conditions to a `PolicyStatement` to make it context-dependent. The evaluator checks all conditions during permission evaluation, and the statement is only applied when every condition passes. + +```php +use DynamikDev\PolicyEngine\DTOs\Condition; +use DynamikDev\PolicyEngine\DTOs\PolicyStatement; +use DynamikDev\PolicyEngine\Enums\Effect; + +$statement = new PolicyStatement( + effect: Effect::Allow, + action: 'documents.update', + conditions: [ + new Condition('attribute_equals', [ + 'subject_key' => 'department_id', + 'resource_key' => 'department_id', + ]), + ], + source: 'policy:same-department', +); +``` + +This statement allows `documents.update` only when the user's `department_id` matches the resource's `department_id`. If the condition fails, the statement is silently excluded from evaluation -- it behaves as if it does not exist. + +Multiple conditions on the same statement are AND-ed together. All must pass for the statement to apply. + +## Using the attribute_equals condition + +Compare an attribute on the principal (user) against an attribute on the resource. Both attributes must exist for the condition to pass. + +```php +new Condition('attribute_equals', [ + 'subject_key' => 'department_id', + 'resource_key' => 'department_id', +]); +``` + +| Parameter | Type | Description | +| -------------- | -------- | ------------------------------------------------ | +| `subject_key` | `string` | Key to read from the principal's attributes | +| `resource_key` | `string` | Key to read from the resource's attributes | + +The condition fails when: +- No resource is present in the evaluation request +- Either key is missing from its respective attributes array +- The values are not strictly equal + +## Using the attribute_in condition + +Check whether a value from any source (principal, resource, or environment) is in an allowed set. + +```php +new Condition('attribute_in', [ + 'source' => 'resource', + 'key' => 'status', + 'values' => ['draft', 'review'], +]); +``` + +| Parameter | Type | Description | +| --------- | ---------- | ---------------------------------------------------- | +| `source` | `string` | Where to read the value: `principal`, `resource`, or `environment` | +| `key` | `string` | Attribute key to look up in the source | +| `values` | `array` | Allowed values (checked with strict comparison) | + +### Checking a principal attribute + +```php +new Condition('attribute_in', [ + 'source' => 'principal', + 'key' => 'tier', + 'values' => ['pro', 'enterprise'], +]); +``` + +### Checking an environment value + +```php +new Condition('attribute_in', [ + 'source' => 'environment', + 'key' => 'region', + 'values' => ['us-east-1', 'us-west-2'], +]); +``` + +> `attribute_in` uses strict type comparison. The integer `1` does not match the string `"1"`. + +## Using the environment_equals condition + +Check a single value in the environment context against an expected value. + +```php +new Condition('environment_equals', [ + 'key' => 'region', + 'value' => 'us-east-1', +]); +``` + +| Parameter | Type | Description | +| --------- | -------- | ---------------------------------------- | +| `key` | `string` | Key to read from `context.environment` | +| `value` | `mixed` | Expected value (strict equality) | + +The condition fails if the key is absent from the environment array. + +## Using the ip_range condition + +Restrict access to requests from specific IP ranges using CIDR notation. + +```php +new Condition('ip_range', [ + 'ranges' => ['10.0.0.0/8', '172.16.0.0/12'], +]); +``` + +| Parameter | Type | Description | +| --------- | ----------------- | ------------------------------ | +| `ranges` | `array` | CIDR ranges or exact IPs | + +The evaluator reads the IP from `context.environment['ip']`. You must pass the IP when calling `canDo()`: + +```php +$user->canDo('admin.access', environment: ['ip' => $request->ip()]); +``` + +Ranges support both CIDR notation (`10.0.0.0/8`) and exact IPs (`192.168.1.100`). The condition passes if the IP matches any range in the array. + +> IPv4 only. The evaluator uses `ip2long()` internally. + +## Using the time_between condition + +Restrict access to a time window within a given timezone. + +```php +new Condition('time_between', [ + 'start' => '09:00', + 'end' => '17:00', + 'timezone' => 'America/New_York', +]); +``` + +| Parameter | Type | Description | +| ---------- | -------- | ---------------------------------------- | +| `start` | `string` | Start time in `H:i` format | +| `end` | `string` | End time in `H:i` format | +| `timezone` | `string` | IANA timezone (defaults to `UTC`) | + +The evaluator compares the current time (via `Carbon::now()`) against the window. Windows that wrap midnight are supported -- a start of `22:00` with an end of `06:00` permits overnight access. + +## Exposing attributes for condition evaluation + +Conditions read attributes from the `Principal` and `Resource` DTOs. Override the attribute methods on your models to control what data is available. + +### Exposing principal attributes + +On any model using `HasPermissions`, override `principalAttributes()`: + +```php +use DynamikDev\PolicyEngine\Concerns\HasPermissions; +use Illuminate\Database\Eloquent\Model; + +class User extends Model +{ + use HasPermissions; + + protected function principalAttributes(): array + { + return [ + 'department_id' => $this->department_id, + 'tier' => $this->subscription?->tier, + ]; + } +} +``` + +These attributes are embedded in the `Principal` DTO whenever `toPrincipal()` is called, which happens automatically during every `canDo()` or Gate check. + +### Exposing resource attributes + +On any model using `HasResourcePolicies`, override `resourceAttributes()`: + +```php +use DynamikDev\PolicyEngine\Concerns\HasResourcePolicies; +use Illuminate\Database\Eloquent\Model; + +class Document extends Model +{ + use HasResourcePolicies; + + protected $fillable = ['title', 'department_id', 'status']; + + protected function resourceAttributes(): array + { + return [ + 'department_id' => $this->department_id, + 'status' => $this->status, + 'owner_id' => $this->owner_id, + ]; + } +} +``` + +The default `resourceAttributes()` returns `$this->only($this->getFillable())`. Override it when you need non-fillable attributes or computed values. + +## Passing environment context + +Environment data is passed through the `environment` parameter on `canDo()`: + +```php +$user->canDo('admin.access', environment: [ + 'ip' => $request->ip(), + 'region' => config('app.region'), +]); +``` + +Through the Gate, environment context is not directly supported. Use `canDo()` when you need to pass environment data: + +```php +class AdminController extends Controller +{ + public function index(Request $request) + { + if ($request->user()->cannotDo('admin.access', environment: ['ip' => $request->ip()])) { + abort(403); + } + + // ... + } +} +``` + +## Registering a custom condition type + +Implement the `ConditionEvaluator` interface and register it with the `ConditionRegistry`. + +```php +use DynamikDev\PolicyEngine\Contracts\ConditionEvaluator; +use DynamikDev\PolicyEngine\DTOs\Condition; +use DynamikDev\PolicyEngine\DTOs\EvaluationRequest; + +class OwnershipEvaluator implements ConditionEvaluator +{ + public function passes(Condition $condition, EvaluationRequest $request): bool + { + if ($request->resource === null) { + return false; + } + + $ownerKey = $condition->parameters['owner_key'] ?? 'owner_id'; + + return ($request->resource->attributes[$ownerKey] ?? null) + === $request->principal->id; + } +} +``` + +### Registering in a service provider + +```php +use DynamikDev\PolicyEngine\Contracts\ConditionRegistry; + +class AppServiceProvider extends ServiceProvider +{ + public function boot(): void + { + app(ConditionRegistry::class)->register('ownership', OwnershipEvaluator::class); + } +} +``` + +After registration, use the type string in any `Condition`: + +```php +new Condition('ownership', ['owner_key' => 'created_by']); +``` + +## Reference + +### Built-in condition types + +| Type | Evaluator Class | Description | +| -------------------- | ---------------------------- | ----------------------------------------------- | +| `attribute_equals` | `AttributeEqualsEvaluator` | Compare principal and resource attribute values | +| `attribute_in` | `AttributeInEvaluator` | Check a value is in an allowed set | +| `environment_equals` | `EnvironmentEqualsEvaluator` | Match an environment context value | +| `ip_range` | `IpRangeEvaluator` | CIDR range check on `environment['ip']` | +| `time_between` | `TimeBetweenEvaluator` | Current time within a window | + +All evaluator classes are in the `DynamikDev\PolicyEngine\Conditions\` namespace. + +### `ConditionEvaluator` + +The contract for custom condition evaluators. + +| Method | Returns | Description | +| ------------------------------------------------------- | ------- | ---------------------------------------- | +| `passes(Condition $condition, EvaluationRequest $request)` | `bool` | Return true if the condition is satisfied | + +### `ConditionRegistry` + +Manages the mapping from condition type strings to evaluator classes. + +| Method | Returns | Description | +| ----------------------------------------------------- | -------------------- | ---------------------------------------- | +| `register(string $type, string $evaluatorClass)` | `void` | Register an evaluator for a type | +| `evaluatorFor(string $type)` | `ConditionEvaluator` | Resolve the evaluator for a type | + +**Default implementation:** `DynamikDev\PolicyEngine\Conditions\DefaultConditionRegistry` diff --git a/docs/authorization/using-resource-policies.md b/docs/authorization/using-resource-policies.md new file mode 100644 index 0000000..76b149f --- /dev/null +++ b/docs/authorization/using-resource-policies.md @@ -0,0 +1,261 @@ +# Using Resource Policies + +Resource policies attach authorization rules directly to a resource instead of (or in addition to) granting them through roles. Use them for per-object access control -- making a document publicly readable, restricting a post to specific users, or denying deletion on archived records. + +## Attaching a policy to a resource + +Add the `HasResourcePolicies` trait to your model, then call `attachPolicy()` with a `PolicyStatement`. + +```php +use DynamikDev\PolicyEngine\Concerns\HasResourcePolicies; +use Illuminate\Database\Eloquent\Model; + +class Post extends Model +{ + use HasResourcePolicies; + + protected $fillable = ['title', 'status']; +} +``` + +```php +use DynamikDev\PolicyEngine\DTOs\PolicyStatement; +use DynamikDev\PolicyEngine\Enums\Effect; + +$post->attachPolicy(new PolicyStatement( + effect: Effect::Allow, + action: 'posts.read', + principalPattern: '*', + source: 'resource-policy', +)); +``` + +This makes `$post` readable by anyone. The `principalPattern: '*'` matches all principals. + +### Restricting to a specific user + +```php +$post->attachPolicy(new PolicyStatement( + effect: Effect::Allow, + action: 'posts.update', + principalPattern: "App\\Models\\User:{$owner->id}", + source: 'resource-policy', +)); +``` + +The `principalPattern` uses the format `type:id`, where `type` is the model's morph class. + +## How resource policies work + +The `ResourcePolicyResolver` collects policies attached to the resource in the evaluation request. These policies are combined with identity policies (from role assignments) and boundary policies. The evaluator applies deny-wins across all sources -- a deny from any resolver blocks access regardless of allows from other resolvers. + +Resource policies only participate in evaluation when a resource is present in the request. Permission checks without a resource (like `$user->canDo('posts.create')`) skip the resource policy resolver entirely. + +## Building a Resource from a model + +The `HasResourcePolicies` trait provides `toPolicyResource()`, which builds a `Resource` DTO from the model. + +```php +$resource = $post->toPolicyResource(); +// Resource { type: 'App\Models\Post', id: 1, attributes: ['title' => '...', 'status' => 'draft'] } +``` + +By default, `resourceAttributes()` returns the model's fillable attributes via `$this->only($this->getFillable())`. Override it to include computed or non-fillable values: + +```php +class Post extends Model +{ + use HasResourcePolicies; + + protected $fillable = ['title', 'status']; + + protected function resourceAttributes(): array + { + return [ + 'title' => $this->title, + 'status' => $this->status, + 'owner_id' => $this->user_id, + 'is_published' => $this->published_at !== null, + ]; + } +} +``` + +> The method is named `toPolicyResource()` (not `toResource()`) to avoid collisions with Laravel API Resources. + +## Passing resources through the Gate + +The Gate hook recognizes three patterns for passing a resource into a permission check. + +### Model with trait as single argument + +```php +$user->can('posts.update', $post); +``` + +When `$post` uses `HasResourcePolicies`, the Gate hook calls `$post->toPolicyResource()` automatically. + +### Scope and resource as two arguments + +```php +$user->can('posts.update', [$team, $post]); +``` + +The first argument is resolved as the scope, the second as the resource. Use this when the permission check is both scoped and resource-aware. + +### Resource DTO directly + +```php +use DynamikDev\PolicyEngine\DTOs\Resource; + +$resource = new Resource( + type: 'posts', + id: $postId, + attributes: ['status' => 'draft'], +); + +$user->can('posts.update', $resource); +``` + +Build the DTO manually when you do not have a model instance or need custom attributes. + +### Passing resources to canDo + +When calling `canDo()` directly, pass the resource via the `resource` parameter: + +```php +$user->canDo('posts.update', resource: $post->toPolicyResource()); +``` + +## Attaching type-level policies + +Pass `null` as the resource ID to create a policy that applies to all instances of a resource type. + +```php +use DynamikDev\PolicyEngine\Contracts\ResourcePolicyStore; +use DynamikDev\PolicyEngine\DTOs\PolicyStatement; +use DynamikDev\PolicyEngine\Enums\Effect; + +app(ResourcePolicyStore::class)->attach( + 'App\\Models\\Post', + null, + new PolicyStatement( + effect: Effect::Deny, + action: 'posts.delete', + source: 'resource-policy', + ), +); +``` + +This denies `posts.delete` on every `Post` instance. Type-level policies are combined with instance-level policies when a specific resource is queried. + +### Instance-level policies + +```php +$post->attachPolicy(new PolicyStatement( + effect: Effect::Allow, + action: 'posts.read', + principalPattern: '*', + source: 'resource-policy', +)); +``` + +This only applies to this specific `$post` instance. + +When both type-level and instance-level policies exist for the same resource, all of them are evaluated together. Deny-wins applies across the combined set. + +## Detaching a policy from a resource + +```php +$post->detachPolicy('posts.read'); +``` + +This removes the policy matching the given action from this specific resource instance. Other actions attached to the same resource are not affected. + +### Detaching a type-level policy + +```php +app(ResourcePolicyStore::class)->detach('App\\Models\\Post', null, 'posts.delete'); +``` + +## Including resource policies in policy documents + +The `resource_policies` array in a policy document defines resource-level authorization rules. + +```json +{ + "version": "2.0", + "permissions": ["posts.read", "posts.update", "posts.delete"], + "resource_policies": [ + { + "resource_type": "App\\Models\\Post", + "resource_id": null, + "effect": "Deny", + "action": "posts.delete", + "principal_pattern": null, + "conditions": [] + }, + { + "resource_type": "App\\Models\\Post", + "resource_id": "42", + "effect": "Allow", + "action": "posts.read", + "principal_pattern": "*", + "conditions": [] + } + ] +} +``` + +Each entry in `resource_policies` requires `resource_type`, `effect`, and `action`. The `resource_id`, `principal_pattern`, and `conditions` fields are optional. + +Set `resource_id` to `null` for type-level policies. Set `principal_pattern` to `"*"` for policies that apply to all users, or use `"type:id"` format to target a specific principal. + +### Adding conditions to resource policies in documents + +```json +{ + "resource_type": "App\\Models\\Post", + "resource_id": null, + "effect": "Allow", + "action": "posts.update", + "principal_pattern": null, + "conditions": [ + { + "type": "attribute_equals", + "parameters": { + "subject_key": "department_id", + "resource_key": "department_id" + } + } + ] +} +``` + +See [using conditions](using-conditions.md) for the full list of condition types and their parameters. + +## Reference + +### `HasResourcePolicies` + +Trait for Eloquent models that act as authorization resources. + +| Method | Returns | Description | +| ------------------------------------------- | ---------------- | ---------------------------------------------- | +| `toPolicyResource()` | `Resource` | Build a Resource DTO from this model | +| `attachPolicy(PolicyStatement $statement)` | `void` | Attach an authorization policy to this instance | +| `detachPolicy(string $action)` | `void` | Remove a policy by action string | + +**Protected method:** `resourceAttributes(): array` -- override to customize the attributes embedded in the Resource DTO. Defaults to fillable attributes. + +### `ResourcePolicyStore` + +Contract for the resource policy persistence layer. + +| Method | Returns | Description | +| ------------------------------------------------------------------------------ | -------------------- | ------------------------------------------- | +| `forResource(string $type, string\|int\|null $id)` | `Collection` | Get all policies for a resource (type + instance level) | +| `attach(string $resourceType, string\|int\|null $resourceId, PolicyStatement $statement)` | `void` | Attach a policy to a resource | +| `detach(string $resourceType, string\|int\|null $resourceId, string $action)` | `void` | Remove a policy by action | + +**Default implementation:** `DynamikDev\PolicyEngine\Stores\EloquentResourcePolicyStore` diff --git a/docs/cli/artisan-commands.md b/docs/cli/artisan-commands.md index cb5eb03..1e10af4 100644 --- a/docs/cli/artisan-commands.md +++ b/docs/cli/artisan-commands.md @@ -72,7 +72,7 @@ Outputs a table with `Subject Type`, `Subject ID`, `Role`, and `Scope` columns. ### `policy-engine:explain` -Show the full evaluation trace for a specific permission check. +Show the evaluation result for a specific permission check. ```bash php artisan policy-engine:explain {subject} {permission} {--scope=} @@ -88,9 +88,23 @@ php artisan policy-engine:explain {subject} {permission} {--scope=} php artisan policy-engine:explain user::42 "posts.delete" --scope="group::5" ``` -Displays the evaluation trace: assignments found, permissions checked per role, boundary status, and the final `ALLOW` or `DENY` result. +Displays the decision (`ALLOW` or `DENY`), the resolver that decided it (`decidedBy`), and any matched policy statements with their effect, action, and source. -> The `explain` config option must be set to `true` for this command to work. If disabled, the command prints an error message. +``` + Subject: user:42 + Permission: posts.delete + Result: DENY + Decided by: role:moderator + Scope: group::5 + + Matched statements: + [ALLOW] posts.* (source: role:moderator) + [DENY] posts.delete (source: boundary:group::5) + + Cache hit: N/A +``` + +> The `trace` config option must be set to `true` for this command to work. If disabled, the command prints an error message. ## Importing a policy document diff --git a/docs/extending/adding-policy-resolvers.md b/docs/extending/adding-policy-resolvers.md new file mode 100644 index 0000000..c8ebbab --- /dev/null +++ b/docs/extending/adding-policy-resolvers.md @@ -0,0 +1,191 @@ +# Adding Policy Resolvers + +Policy resolvers are the sources of authorization statements. The evaluator collects `PolicyStatement` objects from each resolver, filters them against the current request, and applies deny-wins to produce a final decision. Add a custom resolver when you need authorization logic that does not fit into roles, boundaries, or resource policies. + +## Understanding the resolver chain + +The evaluator runs each resolver in the order defined by the `resolvers` config array. Every resolver returns a `Collection` of `PolicyStatement` objects. The evaluator merges all statements, filters them by action/principal/resource/condition matching, then checks for denies before allows. + +```php +// config/policy-engine.php + +'resolvers' => [ + IdentityPolicyResolver::class, + BoundaryPolicyResolver::class, + ResourcePolicyResolver::class, + SanctumPolicyResolver::class, +], +``` + +A deny from any resolver blocks the action regardless of allows from other resolvers. Order matters only for the `decidedBy` trace -- the first matching deny or allow is recorded as the deciding source. + +## Creating a custom resolver + +Implement the `PolicyResolver` interface. The `resolve()` method receives an `EvaluationRequest` and returns a `Collection` of `PolicyStatement` objects. + +```php +use DynamikDev\PolicyEngine\Contracts\PolicyResolver; +use DynamikDev\PolicyEngine\DTOs\EvaluationRequest; +use DynamikDev\PolicyEngine\DTOs\PolicyStatement; +use DynamikDev\PolicyEngine\Enums\Effect; +use Illuminate\Support\Collection; + +class MaintenanceModeResolver implements PolicyResolver +{ + /** + * @return Collection + */ + public function resolve(EvaluationRequest $request): Collection + { + if (! app()->isDownForMaintenance()) { + return collect(); + } + + return collect([ + new PolicyStatement( + effect: Effect::Deny, + action: '*.*', + source: 'maintenance-mode', + ), + ]); + } +} +``` + +This resolver denies everything when the application is in maintenance mode. Return an empty collection when the resolver has nothing to contribute -- the evaluator skips it. + +### Returning conditional statements + +Resolvers can return statements with [conditions](../authorization/using-conditions.md). The evaluator checks conditions during filtering, so the resolver does not need to evaluate them. + +```php +class GeoRestrictionResolver implements PolicyResolver +{ + public function resolve(EvaluationRequest $request): Collection + { + return collect([ + new PolicyStatement( + effect: Effect::Allow, + action: 'streaming.*', + conditions: [ + new Condition('attribute_in', [ + 'source' => 'environment', + 'key' => 'country', + 'values' => ['US', 'CA', 'GB'], + ]), + ], + source: 'geo-restriction', + ), + ]); + } +} +``` + +## Registering your resolver + +Add your resolver class to the `resolvers` array in `config/policy-engine.php`: + +```php +use App\Auth\MaintenanceModeResolver; +use DynamikDev\PolicyEngine\Resolvers\BoundaryPolicyResolver; +use DynamikDev\PolicyEngine\Resolvers\IdentityPolicyResolver; +use DynamikDev\PolicyEngine\Resolvers\ResourcePolicyResolver; +use DynamikDev\PolicyEngine\Resolvers\SanctumPolicyResolver; + +'resolvers' => [ + IdentityPolicyResolver::class, + BoundaryPolicyResolver::class, + ResourcePolicyResolver::class, + SanctumPolicyResolver::class, + MaintenanceModeResolver::class, +], +``` + +Position does not affect deny-wins behavior -- a deny from any resolver blocks access. However, the order determines which resolver's `source` string appears in `decidedBy` when multiple resolvers produce matching statements. + +### Removing a built-in resolver + +Remove a resolver from the array to disable it. For example, to disable Sanctum token scoping: + +```php +'resolvers' => [ + IdentityPolicyResolver::class, + BoundaryPolicyResolver::class, + ResourcePolicyResolver::class, + // SanctumPolicyResolver removed +], +``` + +### Injecting dependencies + +The service container resolves each resolver class. Use constructor injection for dependencies: + +```php +class FeatureFlagResolver implements PolicyResolver +{ + public function __construct( + private readonly FeatureFlagService $flags, + ) {} + + public function resolve(EvaluationRequest $request): Collection + { + if ($this->flags->isEnabled('beta-features')) { + return collect(); + } + + return collect([ + new PolicyStatement( + effect: Effect::Deny, + action: 'beta.*', + source: 'feature-flag:beta-features', + ), + ]); + } +} +``` + +If your resolver needs special construction, bind it in a service provider: + +```php +$this->app->singleton(FeatureFlagResolver::class, function ($app) { + return new FeatureFlagResolver( + flags: $app->make(FeatureFlagService::class), + ); +}); +``` + +## Reference + +### Built-in resolvers + +| Resolver | Description | +| ------------------------- | ------------------------------------------------------------ | +| `IdentityPolicyResolver` | RBAC: assignments to roles to permissions. Allow and deny statements from role permissions. | +| `BoundaryPolicyResolver` | Permission ceilings per scope. Emits deny statements for permissions outside the boundary. | +| `ResourcePolicyResolver` | Policies attached to resources via the `ResourcePolicyStore`. | +| `SanctumPolicyResolver` | Restricts permissions to the current Sanctum token's abilities. Emits denies for anything outside the token scope. | + +All resolver classes are in the `DynamikDev\PolicyEngine\Resolvers\` namespace. + +### `PolicyResolver` + +The contract every resolver implements. + +| Method | Returns | Description | +| ----------------------------------------- | -------------------------------- | ---------------------------------------- | +| `resolve(EvaluationRequest $request)` | `Collection` | Return statements relevant to the request | + +**Default implementations:** `IdentityPolicyResolver`, `BoundaryPolicyResolver`, `ResourcePolicyResolver`, `SanctumPolicyResolver` + +### `PolicyStatement` + +The DTO that resolvers return. + +| Property | Type | Description | +| ------------------ | ------------------- | ------------------------------------------------ | +| `$effect` | `Effect` | `Effect::Allow` or `Effect::Deny` | +| `$action` | `string` | Permission string to match (supports wildcards) | +| `$principalPattern`| `?string` | Principal to match, `"*"` for all, `null` for any | +| `$resourcePattern` | `?string` | Resource to match, `"*"` for all, `null` for any | +| `$conditions` | `array` | Conditions that must pass for this statement | +| `$source` | `string` | Label for tracing (appears in `decidedBy`) | diff --git a/docs/extending/swapping-implementations.md b/docs/extending/swapping-implementations.md index 0ef7be5..0fdb508 100644 --- a/docs/extending/swapping-implementations.md +++ b/docs/extending/swapping-implementations.md @@ -22,9 +22,11 @@ Everything that touches permissions now uses your Redis store. | `RoleStore` | `EloquentRoleStore` | Redis, Postgres views, flat file | | `AssignmentStore` | `EloquentAssignmentStore` | External identity service, LDAP | | `BoundaryStore` | `EloquentBoundaryStore` | Config-driven, plan-based lookup | +| `ResourcePolicyStore` | `EloquentResourcePolicyStore` | JSON file, API-backed | | `Evaluator` | `CachedEvaluator` | Custom caching strategy, external policy engine | | `Matcher` | `WildcardMatcher` | Regex matcher, bitfield matcher | | `ScopeResolver` | `ModelScopeResolver` | DTO-based resolver, string parser | +| `ConditionRegistry` | `DefaultConditionRegistry` | Custom registry with additional condition types | | `DocumentParser` | `JsonDocumentParser` | YAML, TOML, custom format | | `DocumentImporter` | `DefaultDocumentImporter` | Approval queue, audit-logged importer | | `DocumentExporter` | `DefaultDocumentExporter` | Tenant-filtered, redacted exporter | @@ -133,3 +135,49 @@ $this->app->bind(DocumentParser::class, YamlDocumentParser::class); ``` Your YAML format works everywhere the parser contract is used. + +## Adding a custom PolicyResolver + +Extend the evaluation pipeline by adding a `PolicyResolver` to the `resolvers` config array. Each resolver receives an `EvaluationRequest` and returns a collection of `PolicyStatement` objects. + +```php +use DynamikDev\PolicyEngine\Contracts\PolicyResolver; +use DynamikDev\PolicyEngine\DTOs\EvaluationRequest; +use DynamikDev\PolicyEngine\DTOs\PolicyStatement; +use DynamikDev\PolicyEngine\Enums\Effect; +use Illuminate\Support\Collection; + +class MaintenanceModeResolver implements PolicyResolver +{ + public function resolve(EvaluationRequest $request): Collection + { + if (! app()->isDownForMaintenance()) { + return collect(); + } + + return collect([ + new PolicyStatement( + effect: Effect::Deny, + action: '*.*', + source: 'maintenance-mode', + ), + ]); + } +} +``` + +Register it in `config/policy-engine.php`: + +```php +'resolvers' => [ + \DynamikDev\PolicyEngine\Resolvers\IdentityPolicyResolver::class, + \DynamikDev\PolicyEngine\Resolvers\BoundaryPolicyResolver::class, + \DynamikDev\PolicyEngine\Resolvers\ResourcePolicyResolver::class, + \DynamikDev\PolicyEngine\Resolvers\SanctumPolicyResolver::class, + \App\Auth\MaintenanceModeResolver::class, +], +``` + +The evaluator calls every resolver in order and merges the returned statements. Deny statements from any resolver override Allow statements from any other — order does not affect deny-wins logic. + +> Return an empty collection from `resolve()` when your resolver has nothing to contribute. This is a no-op and adds no overhead to the evaluation. diff --git a/docs/integrations/integrating-with-model-policies.md b/docs/integrations/integrating-with-model-policies.md index 199d927..9cc02a0 100644 --- a/docs/integrations/integrating-with-model-policies.md +++ b/docs/integrations/integrating-with-model-policies.md @@ -93,6 +93,41 @@ $this->authorize('delete', $post); // Standard Gate → PostPolicy::delete() When a policy method internally calls `canDo()`, it goes directly to the `HasPermissions` trait — it does not re-enter the Gate. +## Passing a resource model through the Gate + +Models that use the `HasResourcePolicies` trait are automatically detected as resources when passed to `can()`. + +```php +use DynamikDev\PolicyEngine\Concerns\HasResourcePolicies; + +class Post extends Model +{ + use HasResourcePolicies; +} +``` + +```php +$user->can('posts.update', $post); +``` + +The Gate hook calls `$post->toPolicyResource()` and forwards the resulting `Resource` DTO to the evaluator. This makes the resource available to the `ResourcePolicyResolver` and any conditions that inspect resource attributes. + +### Passing both a scope and a resource + +```php +$user->can('posts.update', [$team, $post]); +``` + +When the Gate receives an array, the first argument is treated as the scope and the second is checked for the `HasResourcePolicies` trait. If the second argument has the trait, it is converted to a `Resource` DTO. + +### Scope-only behavior (unchanged) + +```php +$user->can('posts.update', $team); +``` + +When the argument does not have the `HasResourcePolicies` trait, it is treated as a scope — the same behavior as v1. + ## Registering the policy ```php diff --git a/docs/integrations/scoping-sanctum-tokens.md b/docs/integrations/scoping-sanctum-tokens.md index 1c2d1c5..b9968f6 100644 --- a/docs/integrations/scoping-sanctum-tokens.md +++ b/docs/integrations/scoping-sanctum-tokens.md @@ -15,13 +15,14 @@ This token can only use `posts.read` and `posts.create`, even if the user's role ## How token scoping works -When the evaluator processes a permission check, it detects whether the current request is authenticated via a Sanctum `PersonalAccessToken`. If so, it applies an additional gate after the normal role/boundary/deny evaluation: +Token scoping is handled by the `SanctumPolicyResolver` in the resolver chain. When the current request is authenticated via a Sanctum `PersonalAccessToken`, the resolver emits Deny statements for any registered permission not covered by the token's abilities. The evaluator then merges these with statements from other resolvers. -1. Normal evaluation runs (assignments, roles, permissions, boundaries, deny rules) -2. If the normal evaluation returns `deny`, the result is `deny` (token doesn't override) -3. If the normal evaluation returns `allow`, the evaluator checks the token's `abilities` array -4. The token must have an ability that matches the required permission (using the same wildcard matcher) -5. If the token lacks the ability, the result is `deny` +The effective behavior is: + +1. Other resolvers produce Allow/Deny statements from roles, boundaries, and resource policies +2. The `SanctumPolicyResolver` checks the token's `abilities` array +3. Any permission not matched by a token ability gets a Deny statement +4. The evaluator applies deny-wins logic across all statements The token narrows the user's permissions. It can never expand them. @@ -45,6 +46,6 @@ When a user authenticates via session (not a token), token scoping is skipped en ## Behavior when Sanctum is not installed -If `laravel/sanctum` is not installed, token scoping is silently skipped. The evaluator only checks role-based permissions. No configuration needed to opt out. +If `laravel/sanctum` is not installed, the `SanctumPolicyResolver` returns an empty statement collection and has no effect on evaluation. No configuration needed to opt out. You can also remove it from the `resolvers` config array entirely. > Token scoping is a final gate, not a replacement for roles. Prefer specific abilities (`posts.read`, `posts.create`) over broad wildcards. diff --git a/docs/policy-documents/document-format.md b/docs/policy-documents/document-format.md index 11d74aa..e5fb0f8 100644 --- a/docs/policy-documents/document-format.md +++ b/docs/policy-documents/document-format.md @@ -1,12 +1,12 @@ # Understanding the Document Format -Policy documents are portable JSON files that describe permissions, roles, assignments, and boundaries. Use them to version-control your authorization config, sync between environments, or share role templates. +Policy documents are portable JSON files that describe permissions, roles, assignments, boundaries, and resource policies. Use them to version-control your authorization config, sync between environments, or share role templates. ## Document structure ```json { - "version": "1.0", + "version": "2.0", "permissions": [ "posts.read", "posts.create", @@ -15,10 +15,8 @@ Policy documents are portable JSON files that describe permissions, roles, assig "posts.delete.own", "posts.delete.any" ], - "roles": [ - { - "id": "editor", - "name": "Editor", + "roles": { + "editor": { "permissions": [ "posts.read", "posts.create", @@ -26,13 +24,11 @@ Policy documents are portable JSON files that describe permissions, roles, assig "!posts.delete.any" ] }, - { - "id": "admin", - "name": "Admin", - "system": true, - "permissions": ["*.*"] + "admin": { + "permissions": ["*.*"], + "system": true } - ], + }, "assignments": [ { "subject": "user::42", @@ -44,11 +40,20 @@ Policy documents are portable JSON files that describe permissions, roles, assig "role": "admin" } ], - "boundaries": [ - { - "scope": "org::acme", + "boundaries": { + "org::acme": { "max_permissions": ["posts.*", "comments.*"] } + }, + "resource_policies": [ + { + "resource_type": "post", + "resource_id": null, + "effect": "Allow", + "action": "posts.read", + "principal_pattern": "*", + "conditions": [] + } ] } ``` @@ -57,7 +62,7 @@ Policy documents are portable JSON files that describe permissions, roles, assig ### `version` -Required. Currently `"1.0"`. +Required. Export always produces `"2.0"`. The parser still accepts `"1.0"` documents for backward compatibility. ### `permissions` @@ -69,20 +74,47 @@ An array of permission ID strings. These are registered in the `permissions` tab ### `roles` -An array of role objects. Each role has an `id`, `name`, `permissions` array, and an optional `system` flag. +An object keyed by role ID. Each role has a `permissions` array and optional `system` and `conditions` keys. ```json -"roles": [ - { - "id": "moderator", - "name": "Moderator", +"roles": { + "moderator": { "permissions": ["posts.*", "comments.*", "!members.remove"] + }, + "admin": { + "permissions": ["*.*"], + "system": true } -] +} ``` Permissions within a role can include deny rules (prefixed with `!`). The `system` key defaults to `false` when omitted. +### Adding conditions to role permissions + +Attach conditions to specific permissions within a role. The `conditions` key maps a permission string to an array of condition objects. + +```json +"roles": { + "editor": { + "permissions": ["posts.create", "posts.read", "posts.update"], + "conditions": { + "posts.update": [ + { + "type": "attribute_equals", + "parameters": { + "subject_key": "department_id", + "resource_key": "department_id" + } + } + ] + } + } +} +``` + +Each condition object has a `type` string and a `parameters` object. In this example, `posts.update` is only allowed when the subject's `department_id` matches the resource's `department_id`. The `conditions` key is optional — omit it when a role has no conditional permissions. + ### `assignments` An array of assignment objects linking subjects to roles, optionally within a scope. @@ -101,21 +133,64 @@ The `subject` uses the `type::id` format. The `scope` key is optional — omit i ### `boundaries` -An array of boundary objects defining permission ceilings per scope. +An object keyed by scope, defining permission ceilings per scope. ```json -"boundaries": [ - { - "scope": "org::acme", +"boundaries": { + "org::acme": { "max_permissions": ["posts.*", "comments.*"] + }, + "free-tier": { + "max_permissions": ["posts.read"] + } +} +``` + +### `resource_policies` + +An array of resource policy objects that define access rules attached to resource types or specific resource instances. + +```json +"resource_policies": [ + { + "resource_type": "post", + "resource_id": null, + "effect": "Allow", + "action": "posts.read", + "principal_pattern": "*", + "conditions": [] } ] ``` +| Field | Required | Description | +| -------------------- | -------- | ------------------------------------------------------------- | +| `resource_type` | Yes | The resource type this policy applies to (e.g., `"post"`) | +| `resource_id` | No | A specific resource ID, or `null` for all of the type | +| `effect` | Yes | `"Allow"` or `"Deny"` | +| `action` | Yes | The permission string this policy governs | +| `principal_pattern` | No | A pattern matching subjects, or `"*"` for all | +| `conditions` | No | An array of condition objects to evaluate at runtime | + ## Every section is optional A document containing only `roles` is valid — useful for sharing role templates without touching assignments. A document with only `permissions` is valid for registering new permissions. Mix and match sections based on what you need. +```json +{ + "version": "2.0", + "roles": { + "triage": { + "permissions": ["posts.read", "posts.update.any", "!posts.delete.any"] + } + } +} +``` + +## Importing v1 documents + +The parser auto-detects v1 format and normalizes it internally. A v1 document with array-of-objects roles and boundaries imports without changes. + ```json { "version": "1.0", @@ -125,10 +200,20 @@ A document containing only `roles` is valid — useful for sharing role template "name": "Triage", "permissions": ["posts.read", "posts.update.any", "!posts.delete.any"] } + ], + "boundaries": [ + { + "scope": "org::acme", + "max_permissions": ["posts.*"] + } ] } ``` +Missing fields default to empty arrays. A v1 document without `resource_policies` imports the other sections normally and produces no resource policies. + +> Export always produces v2 format (`"version": "2.0"`), even when the original data was imported from a v1 document. + ## Validating a document Use the Artisan command to check a document before importing: @@ -148,7 +233,7 @@ $result->valid; // bool $result->errors; // string[] — empty when valid ``` -Validation checks: `version` is present, `permissions` are strings, roles have `id`/`name`/`permissions`, assignments have `subject`/`role`, boundaries have `scope`/`max_permissions`. +Validation checks: `version` is present, `permissions` are strings, roles have `permissions` arrays, assignments have `subject`/`role`, boundaries have `max_permissions`, and resource policies have `resource_type`/`effect`/`action`. Both v1 and v2 formats are accepted. ## Serialization format @@ -160,4 +245,4 @@ use DynamikDev\PolicyEngine\Contracts\DocumentParser; $json = app(DocumentParser::class)->serialize($document); ``` -Uses `JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES`. +Uses `JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES`. Serialization always outputs v2 format regardless of the input version. diff --git a/docs/policy-documents/importing-and-exporting.md b/docs/policy-documents/importing-and-exporting.md index 049a37a..790d125 100644 --- a/docs/policy-documents/importing-and-exporting.md +++ b/docs/policy-documents/importing-and-exporting.md @@ -1,6 +1,6 @@ # Importing and Exporting Policy Documents -Import JSON documents to apply authorization config. Export your current state to a portable document for version control, environment sync, or backup. See [document format](document-format.md) for the JSON structure. +Import JSON documents to apply authorization config. Export your current state to a portable document for version control, environment sync, or backup. Export always produces v2 format; import accepts both v1 and v2. See [document format](document-format.md) for the JSON structure. ## Importing a document from a file @@ -82,7 +82,7 @@ Imports permissions, roles, and boundaries but ignores the `assignments` section $json = PolicyEngine::export(); ``` -Returns a JSON string containing all permissions, roles, assignments, and boundaries. +Returns a JSON string containing all permissions, roles, assignments, boundaries, and resource policies. Export always produces v2 format (`"version": "2.0"`), even when the data was originally imported from a v1 document. ## Exporting to a file @@ -101,6 +101,7 @@ Scoped export includes: - **Roles** — only roles with at least one assignment in the scope - **Assignments** — only those matching the scope - **Boundaries** — only the boundary for that exact scope +- **Resource policies** — excluded from scoped exports (only included in full exports) ```php PolicyEngine::exportToFile( @@ -150,3 +151,27 @@ See [Artisan commands](../cli/artisan-commands.md) for the full command referenc | Role sharing | Share a role document as a gist or in docs | | Auditing | Diff two exported documents to see what changed | | Approval flow | Custom `DocumentImporter` that queues for admin review | + +## Importing v1 documents + +The importer accepts v1-format documents without any changes. V1 roles (array of objects) and boundaries (array of objects) are normalized internally during parsing. + +```json +{ + "version": "1.0", + "roles": [ + {"id": "editor", "name": "Editor", "permissions": ["posts.create"]} + ], + "boundaries": [ + {"scope": "org::acme", "max_permissions": ["posts.*"]} + ] +} +``` + +```php +$result = PolicyEngine::import($v1JsonString); +``` + +Missing v2 sections like `resource_policies` default to empty. When you re-export after importing a v1 document, the output is v2 format. + +> You do not need to migrate existing v1 documents. Keep them as-is and let the parser handle the conversion. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 4bc0696..aa2b718 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -65,16 +65,16 @@ Useful for audit logging and monitoring. Disable if denial events create too muc --- -### `explain` +### `trace` -Enable the `explain()` evaluation trace. +Enable populated trace data in `EvaluationResult`. - **Type:** `bool` -- **Default:** `env('POLICY_ENGINE_EXPLAIN', false)` +- **Default:** `env('POLICY_ENGINE_TRACE', false)` -When disabled, calling `explain()` throws a `RuntimeException`. Enable in development and staging for debugging. Disable in production — the trace adds overhead and may expose internal authorization structure. +When enabled, `explain()` returns `matchedStatements` and `trace` arrays in the `EvaluationResult`. When disabled, `explain()` still returns the `decision` and `decidedBy` fields, but the arrays are empty. Enable in development and staging for debugging. Disable in production — the trace adds overhead and may expose internal authorization structure. -> **Security:** When enabled, `explain()` exposes the full authorization decision tree — roles, permissions, boundaries, and scope context. Never enable this in production unless access is restricted. The `policy-engine:explain` Artisan command reads this config at runtime; lock down Artisan access in production environments. +> **Security:** When enabled, trace data exposes the full authorization decision tree — roles, permissions, boundaries, resolver sources, and scope context. Never enable this in production unless access is restricted. The `policy-engine:explain` Artisan command reads this config at runtime; lock down Artisan access in production environments. --- @@ -119,6 +119,45 @@ After changing the prefix, you must re-run migrations. If you are adding the pre --- +### `resolvers` + +The ordered list of `PolicyResolver` classes that form the evaluation chain. + +- **Type:** `array` +- **Default:** +```php +'resolvers' => [ + \DynamikDev\PolicyEngine\Resolvers\IdentityPolicyResolver::class, + \DynamikDev\PolicyEngine\Resolvers\BoundaryPolicyResolver::class, + \DynamikDev\PolicyEngine\Resolvers\ResourcePolicyResolver::class, + \DynamikDev\PolicyEngine\Resolvers\SanctumPolicyResolver::class, +], +``` + +Each resolver receives an `EvaluationRequest` and returns a collection of `PolicyStatement` objects (Allow or Deny). The evaluator merges statements from all resolvers and applies deny-wins logic. + +| Resolver | Purpose | +| --- | --- | +| `IdentityPolicyResolver` | Resolves role assignments into Allow/Deny statements | +| `BoundaryPolicyResolver` | Emits Deny statements for permissions outside scope boundaries | +| `ResourcePolicyResolver` | Returns statements attached to a specific resource model | +| `SanctumPolicyResolver` | Emits Deny statements for permissions not in the Sanctum token's abilities | + +Add custom resolvers to this array. Remove `SanctumPolicyResolver` if you do not use Sanctum. See [Adding a custom PolicyResolver](../extending/swapping-implementations.md#adding-a-custom-policyresolver). + +--- + +### `seeder_class` + +The seeder class invoked by `policy-engine:sync`. + +- **Type:** `string` +- **Default:** `'PermissionSeeder'` + +Change this if your permission seeder uses a different class name. + +--- + ### `document_path` Restrict import/export file paths to a specific directory. @@ -175,6 +214,11 @@ When both the morph map and this config are empty, subject type validation is sk ```php // config/policy-engine.php +use DynamikDev\PolicyEngine\Resolvers\BoundaryPolicyResolver; +use DynamikDev\PolicyEngine\Resolvers\IdentityPolicyResolver; +use DynamikDev\PolicyEngine\Resolvers\ResourcePolicyResolver; +use DynamikDev\PolicyEngine\Resolvers\SanctumPolicyResolver; + return [ 'cache' => [ 'enabled' => true, @@ -183,12 +227,20 @@ return [ ], 'protect_system_roles' => true, 'log_denials' => true, - 'explain' => env('POLICY_ENGINE_EXPLAIN', false), + 'trace' => env('POLICY_ENGINE_TRACE', false), 'deny_unbounded_scopes' => false, 'enforce_boundaries_on_global' => false, 'table_prefix' => '', + 'seeder_class' => 'PermissionSeeder', 'document_path' => null, 'gate_passthrough' => [], 'import_subject_types' => [], + + 'resolvers' => [ + IdentityPolicyResolver::class, + BoundaryPolicyResolver::class, + ResourcePolicyResolver::class, + SanctumPolicyResolver::class, + ], ]; ``` diff --git a/docs/reference/contracts.md b/docs/reference/contracts.md index a7b7132..72ec952 100644 --- a/docs/reference/contracts.md +++ b/docs/reference/contracts.md @@ -72,18 +72,71 @@ Manages permission ceilings per scope. ### `Evaluator` -The core authorization engine. +The core authorization engine. Accepts a single `EvaluationRequest` DTO and returns an `EvaluationResult`. | Method | Returns | Description | | --- | --- | --- | -| `can(string $subjectType, string\|int $subjectId, string $permission)` | `bool` | Resolve assignments, roles, permissions, boundaries, deny/allow. | -| `explain(string $subjectType, string\|int $subjectId, string $permission)` | `EvaluationTrace` | Full decision trace for debugging. | -| `effectivePermissions(string $subjectType, string\|int $subjectId, ?string $scope = null)` | `array` | Net permissions after deny rules are applied. | +| `evaluate(EvaluationRequest $request)` | `EvaluationResult` | Run the full evaluation pipeline and return the decision. | + +The `EvaluationRequest` bundles a `Principal`, an action string, an optional `Resource`, and a `Context` (scope + environment). The `EvaluationResult` contains the `Decision` enum, a `decidedBy` string, and optional `matchedStatements` and `trace` arrays (populated when the `trace` config is enabled). **Default implementation:** `DynamikDev\PolicyEngine\Evaluators\CachedEvaluator` (wraps `DefaultEvaluator`) --- +### `PolicyResolver` + +Produces `PolicyStatement` collections for an evaluation request. The evaluator calls every registered resolver and merges their results. + +| Method | Returns | Description | +| --- | --- | --- | +| `resolve(EvaluationRequest $request)` | `Collection` | Return Allow/Deny statements relevant to the request. | + +Each resolver is registered in the `resolvers` config array. See [Adding a custom PolicyResolver](../extending/swapping-implementations.md#adding-a-custom-policyresolver). + +**Default implementations:** `IdentityPolicyResolver`, `BoundaryPolicyResolver`, `ResourcePolicyResolver`, `SanctumPolicyResolver` + +--- + +### `ConditionEvaluator` + +Evaluates a single condition attached to a `PolicyStatement`. + +| Method | Returns | Description | +| --- | --- | --- | +| `passes(Condition $condition, EvaluationRequest $request)` | `bool` | Return whether the condition is satisfied for the request. | + +Condition evaluators are registered in the `ConditionRegistry` by type string. Built-in types: `attribute_equals`, `attribute_in`, `environment_equals`, `ip_range`, `time_between`. + +--- + +### `ConditionRegistry` + +Maps condition type strings to their `ConditionEvaluator` implementations. + +| Method | Returns | Description | +| --- | --- | --- | +| `register(string $type, string $evaluatorClass)` | `void` | Register an evaluator class for a condition type. | +| `evaluatorFor(string $type)` | `ConditionEvaluator` | Retrieve the evaluator for a condition type. | + +**Default implementation:** `DynamikDev\PolicyEngine\Conditions\DefaultConditionRegistry` + +--- + +### `ResourcePolicyStore` + +Manages policy statements attached directly to resource models. + +| Method | Returns | Description | +| --- | --- | --- | +| `forResource(string $type, string\|int\|null $id)` | `Collection` | Get all statements for a resource. | +| `attach(string $resourceType, string\|int\|null $resourceId, PolicyStatement $statement)` | `void` | Attach a statement to a resource. | +| `detach(string $resourceType, string\|int\|null $resourceId, string $action)` | `void` | Detach statements matching an action from a resource. | + +**Default implementation:** `DynamikDev\PolicyEngine\Stores\EloquentResourcePolicyStore` + +--- + ### `Matcher` Determines whether a granted permission covers a required permission, including wildcard resolution. diff --git a/src/Commands/ExplainCommand.php b/src/Commands/ExplainCommand.php index bde2b48..83e3cf0 100644 --- a/src/Commands/ExplainCommand.php +++ b/src/Commands/ExplainCommand.php @@ -5,12 +5,14 @@ namespace DynamikDev\PolicyEngine\Commands; use DynamikDev\PolicyEngine\Contracts\Evaluator; -use DynamikDev\PolicyEngine\DTOs\EvaluationTrace; -use DynamikDev\PolicyEngine\Enums\EvaluationResult; +use DynamikDev\PolicyEngine\DTOs\Context; +use DynamikDev\PolicyEngine\DTOs\EvaluationRequest; +use DynamikDev\PolicyEngine\DTOs\EvaluationResult; +use DynamikDev\PolicyEngine\DTOs\Principal; +use DynamikDev\PolicyEngine\Enums\Decision; use DynamikDev\PolicyEngine\Support\SubjectParser; use Illuminate\Console\Command; use InvalidArgumentException; -use RuntimeException; class ExplainCommand extends Command { @@ -37,63 +39,64 @@ public function handle(Evaluator $evaluator): int return self::FAILURE; } - $scopeOption = $this->option('scope'); - $permission = $this->buildPermissionString( - $permissionArg, - is_string($scopeOption) ? $scopeOption : null, - ); - - try { - $trace = $evaluator->explain($subjectType, $subjectId, $permission); - } catch (RuntimeException $e) { - $this->error('Explain mode is disabled. Set policy-engine.explain to true in your configuration.'); + if (! config('policy-engine.trace')) { + $this->error('Explain mode is disabled. Set policy-engine.trace to true in your configuration.'); return self::FAILURE; } - $this->renderTrace($trace); + $scopeOption = $this->option('scope'); + $scope = is_string($scopeOption) ? $scopeOption : null; - return self::SUCCESS; - } + $request = new EvaluationRequest( + principal: new Principal(type: $subjectType, id: $subjectId), + action: $permissionArg, + resource: null, + context: new Context(scope: $scope), + ); - private function buildPermissionString(string $permission, ?string $scope): string - { - if ($scope === null) { - return $permission; - } + $result = $evaluator->evaluate($request); + + $this->renderResult($subjectType, $subjectId, $permissionArg, $scope, $result); - return $permission.':'.$scope; + return self::SUCCESS; } - private function renderTrace(EvaluationTrace $trace): void - { + private function renderResult( + string $subjectType, + string $subjectId, + string $permission, + ?string $scope, + EvaluationResult $result, + ): void { $this->newLine(); - $this->line(" Subject: {$trace->subject}"); - $this->line(" Permission: {$trace->required}"); + $this->line(" Subject: {$subjectType}:{$subjectId}"); + $this->line(" Permission: {$permission}"); - $resultLabel = strtoupper($trace->result->value); - $resultStyle = $trace->result === EvaluationResult::Allow ? 'info' : 'error'; + $resultLabel = strtoupper($result->decision->value); + $resultStyle = $result->decision === Decision::Allow ? 'info' : 'error'; $this->line(" Result: <{$resultStyle}>{$resultLabel}"); + $this->line(" Decided by: {$result->decidedBy}"); - $this->newLine(); - $this->line(' Assignments checked:'); - - if ($trace->assignments === []) { - $this->line(' (none)'); + if ($scope !== null) { + $this->line(" Scope: {$scope}"); } - foreach ($trace->assignments as $assignment) { - $scopeLabel = $assignment['scope'] ?? 'global'; - $this->line(" Role: {$assignment['role']} (scope: {$scopeLabel})"); + $this->newLine(); + + if ($result->matchedStatements !== []) { + $this->line(' Matched statements:'); - if ($assignment['permissions_checked'] !== []) { - $this->line(' Permissions: '.implode(', ', $assignment['permissions_checked'])); + foreach ($result->matchedStatements as $statement) { + $effect = strtoupper($statement->effect->name); + $this->line(" [{$effect}] {$statement->action} (source: {$statement->source})"); } + } else { + $this->line(' Matched statements: (none)'); } $this->newLine(); - $this->line(' Boundary: '.($trace->boundary ?? 'none')); - $this->line(' Cache hit: '.($trace->cacheHit ? 'Yes' : 'No')); + $this->line(' Cache hit: N/A'); $this->newLine(); } } diff --git a/src/Concerns/HasPermissions.php b/src/Concerns/HasPermissions.php index 77db1d4..649b019 100644 --- a/src/Concerns/HasPermissions.php +++ b/src/Concerns/HasPermissions.php @@ -6,10 +6,19 @@ use DynamikDev\PolicyEngine\Contracts\AssignmentStore; use DynamikDev\PolicyEngine\Contracts\Evaluator; +use DynamikDev\PolicyEngine\Contracts\Matcher; use DynamikDev\PolicyEngine\Contracts\PermissionStore; +use DynamikDev\PolicyEngine\Contracts\PolicyResolver; use DynamikDev\PolicyEngine\Contracts\RoleStore; use DynamikDev\PolicyEngine\Contracts\ScopeResolver; -use DynamikDev\PolicyEngine\DTOs\EvaluationTrace; +use DynamikDev\PolicyEngine\DTOs\Context; +use DynamikDev\PolicyEngine\DTOs\EvaluationRequest; +use DynamikDev\PolicyEngine\DTOs\EvaluationResult; +use DynamikDev\PolicyEngine\DTOs\PolicyStatement; +use DynamikDev\PolicyEngine\DTOs\Principal; +use DynamikDev\PolicyEngine\DTOs\Resource; +use DynamikDev\PolicyEngine\Enums\Decision; +use DynamikDev\PolicyEngine\Enums\Effect; use DynamikDev\PolicyEngine\Models\Assignment; use DynamikDev\PolicyEngine\Models\Role; use Illuminate\Support\Collection; @@ -28,6 +37,32 @@ trait HasPermissions { private const DIRECT_PERMISSION_PREFIX = '__dp.'; + /** + * Build the Principal DTO for this model. + */ + public function toPrincipal(): Principal + { + return new Principal( + type: $this->getMorphClass(), + id: $this->getKey(), + attributes: $this->principalAttributes(), + ); + } + + /** + * Return extra attributes to embed in the Principal DTO. + * + * Override this method on the model to include domain-specific + * context (e.g. department_id, tenant_id) that policy resolvers + * and conditions can inspect. + * + * @return array + */ + protected function principalAttributes(): array + { + return []; + } + /** * Evaluate whether this subject holds a permission, optionally within a scope. * @@ -35,22 +70,47 @@ trait HasPermissions * and Blade directives. For application code, prefer Laravel's * $user->can('permission') which delegates here via the Gate hook. */ - public function canDo(string $permission, mixed $scope = null): bool - { - return app(Evaluator::class)->can( - $this->getMorphClass(), - $this->getKey(), - $this->buildScopedPermission($permission, $scope), + public function canDo( + string $permission, + mixed $scope = null, + ?Resource $resource = null, + array $environment = [], + ): bool { + $result = app(Evaluator::class)->evaluate( + $this->buildEvaluationRequest($permission, $scope, $resource, $environment), ); + + return $result->decision === Decision::Allow; } /** * Inverse of canDo() — returns true when the subject does NOT hold * the given permission. */ - public function cannotDo(string $permission, mixed $scope = null): bool - { - return ! $this->canDo($permission, $scope); + public function cannotDo( + string $permission, + mixed $scope = null, + ?Resource $resource = null, + array $environment = [], + ): bool { + return ! $this->canDo($permission, $scope, $resource, $environment); + } + + /** + * Return the full EvaluationResult for a permission check. + * + * Useful for debugging, audit logging, and policy inspection. The + * trace config controls what is populated in the result. + */ + public function explain( + string $permission, + mixed $scope = null, + ?Resource $resource = null, + array $environment = [], + ): EvaluationResult { + return app(Evaluator::class)->evaluate( + $this->buildEvaluationRequest($permission, $scope, $resource, $environment), + ); } /** @@ -184,6 +244,85 @@ public function hasAllRoles(array $roles, mixed $scope = null): bool return true; } + /** + * Check whether this subject has a specific role assignment. + * + * Queries the AssignmentStore directly rather than going through the + * Evaluator, so the result is not affected by permission logic or cache. + */ + public function hasRole(string $role, mixed $scope = null): bool + { + $resolvedScope = app(ScopeResolver::class)->resolve($scope); + + if ($resolvedScope !== null) { + return app(AssignmentStore::class) + ->forSubjectInScope($this->getMorphClass(), $this->getKey(), $resolvedScope) + ->contains('role_id', $role); + } + + return app(AssignmentStore::class) + ->forSubjectGlobal($this->getMorphClass(), $this->getKey()) + ->contains('role_id', $role); + } + + /** + * Return all effective (net-allowed) permission strings for this subject. + * + * Queries each PolicyResolver in the resolver chain with a wildcard request, + * then subtracts any action covered by a Deny statement. + * + * @return array + */ + public function effectivePermissions(mixed $scope = null): array + { + $resolvedScope = app(ScopeResolver::class)->resolve($scope); + + $request = new EvaluationRequest( + principal: $this->toPrincipal(), + action: '*.*', + resource: null, + context: new Context(scope: $resolvedScope), + ); + + /** @var array> $resolverClasses */ + $resolverClasses = config('policy-engine.resolvers', []); + + $statements = collect(); + + foreach ($resolverClasses as $resolverClass) { + /** @var PolicyResolver $resolver */ + $resolver = app($resolverClass); + $statements = $statements->merge($resolver->resolve($request)); + } + + /** @var Collection $allows */ + $allows = $statements->filter( + static fn (PolicyStatement $s): bool => $s->effect === Effect::Allow, + ); + + /** @var Collection $denies */ + $denies = $statements->filter( + static fn (PolicyStatement $s): bool => $s->effect === Effect::Deny, + ); + + $matcher = app(Matcher::class); + + return $allows + ->filter(function (PolicyStatement $allow) use ($denies, $matcher): bool { + foreach ($denies as $deny) { + if ($matcher->matches($deny->action, $allow->action)) { + return false; + } + } + + return true; + }) + ->pluck('action') + ->unique() + ->values() + ->all(); + } + /** * Build the internal role ID for a direct permission assignment. */ @@ -217,16 +356,6 @@ public function assignmentsFor(mixed $scope): Collection ); } - /** @return array */ - public function effectivePermissions(mixed $scope = null): array - { - return app(Evaluator::class)->effectivePermissions( - $this->getMorphClass(), - $this->getKey(), - app(ScopeResolver::class)->resolve($scope), - ); - } - /** @return Collection */ public function getRoles(): Collection { @@ -254,43 +383,21 @@ public function getRolesFor(mixed $scope): Collection } /** - * Check whether this subject has a specific role assignment. - * - * Delegates to the Evaluator (which caches results via CachedEvaluator). + * Build an EvaluationRequest DTO from the given permission, scope, resource, and environment. */ - public function hasRole(string $role, mixed $scope = null): bool - { - return app(Evaluator::class)->hasRole( - $this->getMorphClass(), - $this->getKey(), - $role, - app(ScopeResolver::class)->resolve($scope), - ); - } - - public function explain(string $permission, mixed $scope = null): EvaluationTrace - { - return app(Evaluator::class)->explain( - $this->getMorphClass(), - $this->getKey(), - $this->buildScopedPermission($permission, $scope), - ); - } - - /** - * Build a permission string with an optional scope suffix. - * - * The evaluator expects the format `permission:scope` when a scope - * is provided, and plain `permission` when unscoped. - */ - private function buildScopedPermission(string $permission, mixed $scope): string - { + private function buildEvaluationRequest( + string $permission, + mixed $scope, + ?Resource $resource, + array $environment, + ): EvaluationRequest { $resolvedScope = app(ScopeResolver::class)->resolve($scope); - if ($resolvedScope === null) { - return $permission; - } - - return $permission.':'.$resolvedScope; + return new EvaluationRequest( + principal: $this->toPrincipal(), + action: $permission, + resource: $resource, + context: new Context(scope: $resolvedScope, environment: $environment), + ); } } diff --git a/src/Concerns/HasResourcePolicies.php b/src/Concerns/HasResourcePolicies.php new file mode 100644 index 0000000..daa8fbc --- /dev/null +++ b/src/Concerns/HasResourcePolicies.php @@ -0,0 +1,45 @@ +getMorphClass(), + id: $this->getKey(), + attributes: $this->resourceAttributes(), + ); + } + + /** @return array */ + protected function resourceAttributes(): array + { + return $this->only($this->getFillable()); + } + + public function attachPolicy(PolicyStatement $statement): void + { + app(ResourcePolicyStore::class)->attach( + $this->getMorphClass(), + $this->getKey(), + $statement, + ); + } + + public function detachPolicy(string $action): void + { + app(ResourcePolicyStore::class)->detach( + $this->getMorphClass(), + $this->getKey(), + $action, + ); + } +} diff --git a/src/Conditions/AttributeEqualsEvaluator.php b/src/Conditions/AttributeEqualsEvaluator.php new file mode 100644 index 0000000..39c854f --- /dev/null +++ b/src/Conditions/AttributeEqualsEvaluator.php @@ -0,0 +1,36 @@ +resource === null) { + return false; + } + + $subjectKey = $condition->parameters['subject_key'] ?? null; + $resourceKey = $condition->parameters['resource_key'] ?? null; + + if (! is_string($subjectKey) || ! is_string($resourceKey)) { + return false; + } + + if (! array_key_exists($subjectKey, $request->principal->attributes)) { + return false; + } + + if (! array_key_exists($resourceKey, $request->resource->attributes)) { + return false; + } + + return $request->principal->attributes[$subjectKey] === $request->resource->attributes[$resourceKey]; + } +} diff --git a/src/Conditions/AttributeInEvaluator.php b/src/Conditions/AttributeInEvaluator.php new file mode 100644 index 0000000..f5ffc5b --- /dev/null +++ b/src/Conditions/AttributeInEvaluator.php @@ -0,0 +1,49 @@ +parameters['source'] ?? null; + $key = $condition->parameters['key'] ?? null; + $values = $condition->parameters['values'] ?? null; + + if (! is_string($source) || ! is_string($key) || ! is_array($values)) { + return false; + } + + $actual = match ($source) { + 'principal' => $request->principal->attributes[$key] ?? null, + 'resource' => $request->resource?->attributes[$key] ?? null, + 'environment' => $request->context->environment[$key] ?? null, + default => null, + }; + + if ($actual === null && ! array_key_exists($key, $this->resolveSourceAttributes($source, $request))) { + return false; + } + + return in_array($actual, $values, strict: true); + } + + /** + * @return array + */ + private function resolveSourceAttributes(string $source, EvaluationRequest $request): array + { + return match ($source) { + 'principal' => $request->principal->attributes, + 'resource' => $request->resource->attributes ?? [], + 'environment' => $request->context->environment, + default => [], + }; + } +} diff --git a/src/Conditions/DefaultConditionRegistry.php b/src/Conditions/DefaultConditionRegistry.php new file mode 100644 index 0000000..55f1c9f --- /dev/null +++ b/src/Conditions/DefaultConditionRegistry.php @@ -0,0 +1,29 @@ + */ + private array $map = []; + + public function register(string $type, string $evaluatorClass): void + { + $this->map[$type] = $evaluatorClass; + } + + public function evaluatorFor(string $type): ConditionEvaluator + { + if (! isset($this->map[$type])) { + throw new InvalidArgumentException("No condition evaluator registered for type [{$type}]."); + } + + return app($this->map[$type]); + } +} diff --git a/src/Conditions/EnvironmentEqualsEvaluator.php b/src/Conditions/EnvironmentEqualsEvaluator.php new file mode 100644 index 0000000..1ba32e8 --- /dev/null +++ b/src/Conditions/EnvironmentEqualsEvaluator.php @@ -0,0 +1,28 @@ +parameters['key'] ?? null; + $expected = $condition->parameters['value'] ?? null; + + if (! is_string($key)) { + return false; + } + + if (! array_key_exists($key, $request->context->environment)) { + return false; + } + + return $request->context->environment[$key] === $expected; + } +} diff --git a/src/Conditions/IpRangeEvaluator.php b/src/Conditions/IpRangeEvaluator.php new file mode 100644 index 0000000..c0ed032 --- /dev/null +++ b/src/Conditions/IpRangeEvaluator.php @@ -0,0 +1,76 @@ +parameters['ranges'] ?? null; + + if (! is_array($ranges) || $ranges === []) { + return false; + } + + $ip = $request->context->environment['ip'] ?? null; + + if (! is_string($ip) || $ip === '') { + return false; + } + + $ipLong = ip2long($ip); + + if ($ipLong === false) { + return false; + } + + foreach ($ranges as $range) { + if (! is_string($range)) { + continue; + } + + if ($this->ipMatchesRange($ipLong, $range)) { + return true; + } + } + + return false; + } + + private function ipMatchesRange(int $ipLong, string $range): bool + { + if (str_contains($range, '/')) { + [$network, $bits] = explode('/', $range, 2); + + $bits = (int) $bits; + + if ($bits < 0 || $bits > 32) { + return false; + } + + $networkLong = ip2long($network); + + if ($networkLong === false) { + return false; + } + + $mask = $bits === 0 ? 0 : (~0 << (32 - $bits)); + + return ($ipLong & $mask) === ($networkLong & $mask); + } + + $exactLong = ip2long($range); + + if ($exactLong === false) { + return false; + } + + return $ipLong === $exactLong; + } +} diff --git a/src/Conditions/TimeBetweenEvaluator.php b/src/Conditions/TimeBetweenEvaluator.php new file mode 100644 index 0000000..e5451d8 --- /dev/null +++ b/src/Conditions/TimeBetweenEvaluator.php @@ -0,0 +1,48 @@ +parameters['start'] ?? null; + $end = $condition->parameters['end'] ?? null; + $timezone = $condition->parameters['timezone'] ?? 'UTC'; + + if (! is_string($start) || ! is_string($end) || $start === '' || $end === '' || ! is_string($timezone)) { + return false; + } + + try { + $now = Carbon::now($timezone); + $startTime = Carbon::createFromFormat('H:i', $start, $timezone); + $endTime = Carbon::createFromFormat('H:i', $end, $timezone); + } catch (Throwable) { + return false; + } + + if ($startTime === null || $endTime === null) { + return false; + } + + $nowMinutes = $now->hour * 60 + $now->minute; + $startMinutes = $startTime->hour * 60 + $startTime->minute; + $endMinutes = $endTime->hour * 60 + $endTime->minute; + + if ($startMinutes <= $endMinutes) { + return $nowMinutes >= $startMinutes && $nowMinutes < $endMinutes; + } + + // Wraps midnight + return $nowMinutes >= $startMinutes || $nowMinutes < $endMinutes; + } +} diff --git a/src/Contracts/ConditionEvaluator.php b/src/Contracts/ConditionEvaluator.php new file mode 100644 index 0000000..1d2add5 --- /dev/null +++ b/src/Contracts/ConditionEvaluator.php @@ -0,0 +1,13 @@ + - */ - public function effectivePermissions(string $subjectType, string|int $subjectId, ?string $scope = null): array; + public function evaluate(EvaluationRequest $request): EvaluationResult; } diff --git a/src/Contracts/PolicyResolver.php b/src/Contracts/PolicyResolver.php new file mode 100644 index 0000000..f2f5665 --- /dev/null +++ b/src/Contracts/PolicyResolver.php @@ -0,0 +1,17 @@ + + */ + public function resolve(EvaluationRequest $request): Collection; +} diff --git a/src/Contracts/ResourcePolicyStore.php b/src/Contracts/ResourcePolicyStore.php new file mode 100644 index 0000000..0442e4e --- /dev/null +++ b/src/Contracts/ResourcePolicyStore.php @@ -0,0 +1,20 @@ + + */ + public function forResource(string $type, string|int|null $id): Collection; + + public function attach(string $resourceType, string|int|null $resourceId, PolicyStatement $statement): void; + + public function detach(string $resourceType, string|int|null $resourceId, string $action): void; +} diff --git a/src/DTOs/Condition.php b/src/DTOs/Condition.php new file mode 100644 index 0000000..8f0284e --- /dev/null +++ b/src/DTOs/Condition.php @@ -0,0 +1,16 @@ + $parameters + */ + public function __construct( + public string $type, + public array $parameters, + ) {} +} diff --git a/src/DTOs/Context.php b/src/DTOs/Context.php new file mode 100644 index 0000000..1c06e35 --- /dev/null +++ b/src/DTOs/Context.php @@ -0,0 +1,16 @@ + $environment + */ + public function __construct( + public ?string $scope = null, + public array $environment = [], + ) {} +} diff --git a/src/DTOs/EvaluationRequest.php b/src/DTOs/EvaluationRequest.php new file mode 100644 index 0000000..c4095d8 --- /dev/null +++ b/src/DTOs/EvaluationRequest.php @@ -0,0 +1,15 @@ + $matchedStatements + * @param array $trace + */ + public function __construct( + public Decision $decision, + public string $decidedBy, + public array $matchedStatements = [], + public array $trace = [], + ) {} +} diff --git a/src/DTOs/EvaluationTrace.php b/src/DTOs/EvaluationTrace.php deleted file mode 100644 index 7938751..0000000 --- a/src/DTOs/EvaluationTrace.php +++ /dev/null @@ -1,23 +0,0 @@ -}> $assignments - */ - public function __construct( - public string $subject, - public string $required, - public EvaluationResult $result, - public array $assignments, - public ?string $boundary, - public bool $cacheHit, - public ?string $sanctum = null, - ) {} -} diff --git a/src/DTOs/PolicyDocument.php b/src/DTOs/PolicyDocument.php index ace7d55..a40035f 100644 --- a/src/DTOs/PolicyDocument.php +++ b/src/DTOs/PolicyDocument.php @@ -8,9 +8,10 @@ { /** * @param array $permissions - * @param array, system?: bool}> $roles + * @param array, system?: bool}>|array, conditions?: array>>}> $roles * @param array $assignments - * @param array}> $boundaries + * @param array}>|array}> $boundaries + * @param array}> $resourcePolicies */ public function __construct( public string $version = '1.0', @@ -18,5 +19,6 @@ public function __construct( public array $roles = [], public array $assignments = [], public array $boundaries = [], + public array $resourcePolicies = [], ) {} } diff --git a/src/DTOs/PolicyStatement.php b/src/DTOs/PolicyStatement.php new file mode 100644 index 0000000..b2be57a --- /dev/null +++ b/src/DTOs/PolicyStatement.php @@ -0,0 +1,22 @@ + $conditions + */ + public function __construct( + public Effect $effect, + public string $action, + public ?string $principalPattern = null, + public ?string $resourcePattern = null, + public array $conditions = [], + public string $source = '', + ) {} +} diff --git a/src/DTOs/Principal.php b/src/DTOs/Principal.php new file mode 100644 index 0000000..2b037ac --- /dev/null +++ b/src/DTOs/Principal.php @@ -0,0 +1,17 @@ + $attributes + */ + public function __construct( + public string $type, + public string|int $id, + public array $attributes = [], + ) {} +} diff --git a/src/DTOs/Resource.php b/src/DTOs/Resource.php new file mode 100644 index 0000000..12e2cfb --- /dev/null +++ b/src/DTOs/Resource.php @@ -0,0 +1,17 @@ + $attributes + */ + public function __construct( + public string $type, + public string|int|null $id = null, + public array $attributes = [], + ) {} +} diff --git a/src/Documents/DefaultDocumentExporter.php b/src/Documents/DefaultDocumentExporter.php index c3a0883..c1568aa 100644 --- a/src/Documents/DefaultDocumentExporter.php +++ b/src/Documents/DefaultDocumentExporter.php @@ -12,6 +12,7 @@ use DynamikDev\PolicyEngine\DTOs\PolicyDocument; use DynamikDev\PolicyEngine\Models\Assignment; use DynamikDev\PolicyEngine\Models\Boundary; +use DynamikDev\PolicyEngine\Models\ResourcePolicy; use DynamikDev\PolicyEngine\Models\Role; use Illuminate\Support\Collection; @@ -30,11 +31,12 @@ public function export(?string $scope = null): PolicyDocument $assignments = $this->exportAssignments($scope); return new PolicyDocument( - version: '1.0', + version: '2.0', permissions: $permissions, roles: $this->exportRoles($scope, $assignments), assignments: $this->serializeAssignments($assignments), boundaries: $this->exportBoundaries($scope), + resourcePolicies: $scope === null ? $this->exportResourcePolicies() : [], ); } @@ -64,12 +66,12 @@ private function exportAssignments(?string $scope): Collection } /** - * Export roles as document arrays. + * Export roles in keyed format. * * When a scope is provided, only roles that have assignments in that scope are included. * * @param Collection $assignments - * @return array, system?: bool}> + * @return array, system?: bool}> */ private function exportRoles(?string $scope, Collection $assignments): array { @@ -77,19 +79,23 @@ private function exportRoles(?string $scope, Collection $assignments): array ? $this->roleStore->all() : $this->roleStore->all()->whereIn('id', $assignments->pluck('role_id')->unique()); - return $roles->map(fn (Role $role): array => $this->serializeRole($role))->values()->all(); + $result = []; + + foreach ($roles as $role) { + $result[$role->id] = $this->serializeKeyedRole($role); + } + + return $result; } /** - * Serialize a single role model into a document array. + * Serialize a single role model into keyed format. * - * @return array{id: string, name: string, permissions: array, system?: bool} + * @return array{permissions: array, system?: bool} */ - private function serializeRole(Role $role): array + private function serializeKeyedRole(Role $role): array { $data = [ - 'id' => $role->id, - 'name' => $role->name, 'permissions' => $this->roleStore->permissionsFor($role->id), ]; @@ -123,19 +129,24 @@ private function serializeAssignments(Collection $assignments): array } /** - * Export boundaries as document arrays. + * Export boundaries in keyed format. * * When a scope is provided, only the boundary for that scope (if it exists) is included. * - * @return array}> + * @return array}> */ private function exportBoundaries(?string $scope): array { if ($scope === null) { - return $this->boundaryStore->all()->map(static fn (Boundary $boundary): array => [ - 'scope' => $boundary->scope, - 'max_permissions' => $boundary->max_permissions, - ])->values()->all(); + $result = []; + + foreach ($this->boundaryStore->all() as $boundary) { + $result[$boundary->scope] = [ + 'max_permissions' => $boundary->max_permissions, + ]; + } + + return $result; } $boundary = $this->boundaryStore->find($scope); @@ -145,10 +156,30 @@ private function exportBoundaries(?string $scope): array } return [ - [ - 'scope' => $boundary->scope, + $boundary->scope => [ 'max_permissions' => $boundary->max_permissions, ], ]; } + + /** + * Export all resource policies as document arrays. + * + * @return array}> + */ + private function exportResourcePolicies(): array + { + return ResourcePolicy::query() + ->get() + ->map(static fn (ResourcePolicy $rp): array => [ + 'resource_type' => $rp->resource_type, + 'resource_id' => $rp->resource_id, + 'effect' => $rp->effect, + 'action' => $rp->action, + 'principal_pattern' => $rp->principal_pattern, + 'conditions' => $rp->conditions ?? [], + ]) + ->values() + ->all(); + } } diff --git a/src/Documents/DefaultDocumentImporter.php b/src/Documents/DefaultDocumentImporter.php index 2b8f237..e88f595 100644 --- a/src/Documents/DefaultDocumentImporter.php +++ b/src/Documents/DefaultDocumentImporter.php @@ -8,12 +8,17 @@ use DynamikDev\PolicyEngine\Contracts\BoundaryStore; use DynamikDev\PolicyEngine\Contracts\DocumentImporter; use DynamikDev\PolicyEngine\Contracts\PermissionStore; +use DynamikDev\PolicyEngine\Contracts\ResourcePolicyStore; use DynamikDev\PolicyEngine\Contracts\RoleStore; +use DynamikDev\PolicyEngine\DTOs\Condition; use DynamikDev\PolicyEngine\DTOs\ImportOptions; use DynamikDev\PolicyEngine\DTOs\ImportResult; use DynamikDev\PolicyEngine\DTOs\PolicyDocument; +use DynamikDev\PolicyEngine\DTOs\PolicyStatement; +use DynamikDev\PolicyEngine\Enums\Effect; use DynamikDev\PolicyEngine\Events\DocumentImported; use DynamikDev\PolicyEngine\Models\Permission; +use DynamikDev\PolicyEngine\Models\ResourcePolicy; use DynamikDev\PolicyEngine\Support\SubjectParser; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\Event; @@ -26,6 +31,7 @@ public function __construct( private readonly RoleStore $roleStore, private readonly AssignmentStore $assignmentStore, private readonly BoundaryStore $boundaryStore, + private readonly ResourcePolicyStore $resourcePolicyStore, ) {} public function import(PolicyDocument $document, ImportOptions $options): ImportResult @@ -48,6 +54,7 @@ public function import(PolicyDocument $document, ImportOptions $options): Import $rolesResult = $this->importRoles($document, $options, $isReplace); $this->importBoundaries($document, $options); $assignmentsCreated = $this->importAssignments($document, $options); + $this->importResourcePolicies($document, $options); $warnings = [...$warnings, ...$rolesResult['warnings']]; @@ -77,7 +84,9 @@ private function collectValidationWarnings(PolicyDocument $document): array $warnings = []; $documentPermissions = array_flip($document->permissions); - foreach ($document->roles as $role) { + $normalizedRoles = $this->normalizeRoles($document->roles); + + foreach ($normalizedRoles as $role) { foreach ($role['permissions'] as $permission) { if (! isset($documentPermissions[$permission]) && ! $this->permissionStore->exists($permission)) { $warnings[] = "Permission '{$permission}' referenced in role '{$role['id']}' is not registered"; @@ -89,12 +98,13 @@ private function collectValidationWarnings(PolicyDocument $document): array } /** - * Remove all existing permissions, roles, assignments, and boundaries via store contracts. + * Remove all existing permissions, roles, assignments, boundaries, and resource policies via store contracts. * * Dispatches removal events for each entity so cache invalidation listeners fire. */ private function clearAllData(): void { + ResourcePolicy::query()->delete(); $this->assignmentStore->removeAll(); $this->boundaryStore->removeAll(); $this->roleStore->removeAll(); @@ -126,7 +136,8 @@ private function importPermissions(PolicyDocument $document, ImportOptions $opti } /** - * Import roles from the document. + * Import roles from the document. Supports both indexed (array of {id, name, permissions}) and + * keyed (by ID) formats via normalization. * * @return array{created: array, updated: array, warnings: array} */ @@ -136,7 +147,9 @@ private function importRoles(PolicyDocument $document, ImportOptions $options, b $updated = []; $warnings = []; - foreach ($document->roles as $role) { + $normalizedRoles = $this->normalizeRoles($document->roles); + + foreach ($normalizedRoles as $role) { $existing = $this->roleStore->find($role['id']); $isNew = $isReplace || $existing === null; @@ -166,7 +179,8 @@ private function importRoles(PolicyDocument $document, ImportOptions $options, b } /** - * Import boundaries from the document. + * Import boundaries from the document. Supports both indexed (array of {scope, max_permissions}) and + * keyed (by scope string) formats via normalization. */ private function importBoundaries(PolicyDocument $document, ImportOptions $options): void { @@ -174,11 +188,46 @@ private function importBoundaries(PolicyDocument $document, ImportOptions $optio return; } - foreach ($document->boundaries as $boundary) { + foreach ($this->normalizeBoundaries($document->boundaries) as $boundary) { $this->boundaryStore->set($boundary['scope'], $boundary['max_permissions']); } } + /** + * Normalize boundaries from either indexed (array of objects) or keyed (by scope) format + * into a canonical array of {scope, max_permissions}. + * + * @param array $boundaries + * @return array}> + */ + private function normalizeBoundaries(array $boundaries): array + { + if ($boundaries === []) { + return []; + } + + // Keyed: associative array keyed by scope string + if (array_keys($boundaries) !== range(0, count($boundaries) - 1)) { + $result = []; + + foreach ($boundaries as $scope => $boundaryData) { + $data = is_array($boundaryData) ? $boundaryData : []; + /** @var array $maxPermissions */ + $maxPermissions = $data['max_permissions'] ?? []; + $result[] = [ + 'scope' => (string) $scope, + 'max_permissions' => $maxPermissions, + ]; + } + + return $result; + } + + // Indexed array of boundary objects — return as-is + /** @var array}> */ + return $boundaries; + } + /** * Import assignments from the document. */ @@ -210,6 +259,94 @@ private function importAssignments(PolicyDocument $document, ImportOptions $opti return count($document->assignments); } + /** + * Import resource policies from the document. + */ + private function importResourcePolicies(PolicyDocument $document, ImportOptions $options): void + { + if ($options->dryRun || $document->resourcePolicies === []) { + return; + } + + foreach ($document->resourcePolicies as $entry) { + $conditions = $this->hydrateConditions($entry['conditions']); + + $statement = new PolicyStatement( + effect: Effect::{$entry['effect']}, + action: $entry['action'], + principalPattern: $entry['principal_pattern'] ?? null, + resourcePattern: null, + conditions: $conditions, + source: 'document', + ); + + $this->resourcePolicyStore->attach( + resourceType: $entry['resource_type'], + resourceId: $entry['resource_id'] ?? null, + statement: $statement, + ); + } + } + + /** + * Normalize roles from either indexed (array of objects) or keyed (by ID) format + * into a canonical array of {id, name, permissions, system?}. + * + * @param array $roles + * @return array, system?: bool}> + */ + private function normalizeRoles(array $roles): array + { + if ($roles === []) { + return []; + } + + // Keyed: associative array keyed by role ID + if (array_keys($roles) !== range(0, count($roles) - 1)) { + $result = []; + + foreach ($roles as $roleId => $roleData) { + $data = is_array($roleData) ? $roleData : []; + /** @var array $permissions */ + $permissions = $data['permissions'] ?? []; + $entry = [ + 'id' => (string) $roleId, + 'name' => is_string($data['name'] ?? null) ? $data['name'] : (string) $roleId, + 'permissions' => $permissions, + ]; + + if (! empty($data['system'])) { + $entry['system'] = true; + } + + $result[] = $entry; + } + + return $result; + } + + // Indexed array of role objects — return as-is + /** @var array, system?: bool}> */ + return $roles; + } + + /** + * Hydrate raw condition arrays into Condition DTOs. + * + * @param array $raw + * @return array + */ + private function hydrateConditions(array $raw): array + { + return array_map( + static fn (array $item) => new Condition( + type: $item['type'], + parameters: $item['parameters'] ?? [], + ), + array_filter($raw, 'is_array'), + ); + } + /** * Build the set of allowed subject types from the morph map and config whitelist. * diff --git a/src/Documents/JsonDocumentParser.php b/src/Documents/JsonDocumentParser.php index d0f638f..07e9854 100644 --- a/src/Documents/JsonDocumentParser.php +++ b/src/Documents/JsonDocumentParser.php @@ -21,23 +21,50 @@ public function parse(string $content): PolicyDocument /** @var string $version */ $version = $data['version'] ?? '1.0'; + $rawRoles = is_array($data['roles'] ?? null) ? $data['roles'] : []; + $rawBoundaries = is_array($data['boundaries'] ?? null) ? $data['boundaries'] : []; + + $isKeyed = $this->isAssociativeArray($rawRoles) && $rawRoles !== []; + + /** @var array, system?: bool}|array{permissions: array}> $roles */ + $roles = $isKeyed + ? $this->parseKeyedRoles($rawRoles) + : $rawRoles; + + /** @var array}> $boundaries */ + $boundaries = ($this->isAssociativeArray($rawBoundaries) && $rawBoundaries !== []) + ? $this->parseKeyedBoundaries($rawBoundaries) + : $rawBoundaries; + + $resourcePolicies = $this->parseResourcePolicies($data['resource_policies'] ?? []); + + /** @var array $permissions */ + $permissions = $data['permissions'] ?? []; + /** @var array $assignments */ + $assignments = $data['assignments'] ?? []; + return new PolicyDocument( version: $version, - permissions: $data['permissions'] ?? [], - roles: $data['roles'] ?? [], - assignments: $data['assignments'] ?? [], - boundaries: $data['boundaries'] ?? [], + permissions: $permissions, + roles: $roles, + assignments: $assignments, + boundaries: $boundaries, + resourcePolicies: $resourcePolicies, ); } public function serialize(PolicyDocument $document): string { + $roles = $this->serializeRolesToKeyed($document->roles); + $boundaries = $this->serializeBoundariesToKeyed($document->boundaries); + return json_encode([ - 'version' => $document->version, + 'version' => '2.0', 'permissions' => $document->permissions, - 'roles' => $document->roles, + 'roles' => $roles, 'assignments' => $document->assignments, - 'boundaries' => $document->boundaries, + 'boundaries' => $boundaries, + 'resource_policies' => $document->resourcePolicies, ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); } @@ -60,7 +87,13 @@ public function validate(string $content): ValidationResult } if (array_key_exists('roles', $data)) { - $this->validateRoles($data['roles'], $errors); + $rawRoles = $data['roles']; + if (is_array($rawRoles) && $this->isAssociativeArray($rawRoles) && $rawRoles !== []) { + /** @var array $rawRoles */ + $this->validateKeyedRoles($rawRoles, $errors); + } else { + $this->validateRoles($rawRoles, $errors); + } } if (array_key_exists('assignments', $data)) { @@ -68,7 +101,17 @@ public function validate(string $content): ValidationResult } if (array_key_exists('boundaries', $data)) { - $this->validateBoundaries($data['boundaries'], $errors); + $rawBoundaries = $data['boundaries']; + if (is_array($rawBoundaries) && $this->isAssociativeArray($rawBoundaries) && $rawBoundaries !== []) { + /** @var array $rawBoundaries */ + $this->validateKeyedBoundaries($rawBoundaries, $errors); + } else { + $this->validateBoundaries($rawBoundaries, $errors); + } + } + + if (array_key_exists('resource_policies', $data)) { + $this->validateResourcePolicies($data['resource_policies'], $errors); } return new ValidationResult( @@ -77,6 +120,193 @@ public function validate(string $content): ValidationResult ); } + /** + * Determine whether an array is associative (string keys) vs indexed. + * + * @param array $array + */ + private function isAssociativeArray(array $array): bool + { + if ($array === []) { + return false; + } + + return array_keys($array) !== range(0, count($array) - 1); + } + + /** + * Parse keyed roles (associative: id => data) into indexed array-of-objects format. + * + * @param array $rawRoles + * @return array, system?: bool}> + */ + private function parseKeyedRoles(array $rawRoles): array + { + $result = []; + + foreach ($rawRoles as $roleId => $roleData) { + if (! is_array($roleData)) { + continue; + } + + /** @var array $permissions */ + $permissions = $roleData['permissions'] ?? []; + $id = (string) $roleId; + $entry = [ + 'id' => $id, + 'name' => is_string($roleData['name'] ?? null) ? $roleData['name'] : $id, + 'permissions' => $permissions, + ]; + + if (! empty($roleData['system'])) { + $entry['system'] = true; + } + + $result[] = $entry; + } + + return $result; + } + + /** + * Parse keyed boundaries (associative: scope => data) into indexed array-of-objects format. + * + * @param array $rawBoundaries + * @return array}> + */ + private function parseKeyedBoundaries(array $rawBoundaries): array + { + $result = []; + + foreach ($rawBoundaries as $scope => $boundaryData) { + if (! is_array($boundaryData)) { + continue; + } + + /** @var array $maxPermissions */ + $maxPermissions = $boundaryData['max_permissions'] ?? []; + $result[] = [ + 'scope' => (string) $scope, + 'max_permissions' => $maxPermissions, + ]; + } + + return $result; + } + + /** + * Parse resource_policies array from raw document data. + * + * @return array}> + */ + private function parseResourcePolicies(mixed $raw): array + { + if (! is_array($raw)) { + return []; + } + + $result = []; + + foreach ($raw as $entry) { + if (! is_array($entry)) { + continue; + } + + $result[] = [ + 'resource_type' => is_string($entry['resource_type'] ?? null) ? $entry['resource_type'] : '', + 'resource_id' => isset($entry['resource_id']) && is_scalar($entry['resource_id']) ? (string) $entry['resource_id'] : null, + 'effect' => is_string($entry['effect'] ?? null) ? $entry['effect'] : 'Allow', + 'action' => is_string($entry['action'] ?? null) ? $entry['action'] : '', + 'principal_pattern' => isset($entry['principal_pattern']) && is_string($entry['principal_pattern']) ? $entry['principal_pattern'] : null, + 'conditions' => is_array($entry['conditions'] ?? null) ? $entry['conditions'] : [], + ]; + } + + return $result; + } + + /** + * Convert indexed roles to keyed format for serialization. + * + * @param array $roles + * @return array, system?: bool}> + */ + private function serializeRolesToKeyed(array $roles): array + { + if ($roles === []) { + return []; + } + + // Already in keyed format (associative with string keys pointing to arrays without 'id') + if ($this->isAssociativeArray($roles)) { + /** @var array, system?: bool}> */ + return $roles; + } + + // Indexed format — convert to keyed + $result = []; + + foreach ($roles as $role) { + if (! is_array($role) || ! isset($role['id'])) { + continue; + } + + /** @var array $permissions */ + $permissions = $role['permissions'] ?? []; + $entry = [ + 'permissions' => $permissions, + ]; + + if (! empty($role['system'])) { + $entry['system'] = true; + } + + /** @var string $roleId */ + $roleId = $role['id']; + $result[$roleId] = $entry; + } + + return $result; + } + + /** + * Convert indexed boundaries to keyed format for serialization. + * + * @param array $boundaries + * @return array}> + */ + private function serializeBoundariesToKeyed(array $boundaries): array + { + if ($boundaries === []) { + return []; + } + + // Already in keyed format + if ($this->isAssociativeArray($boundaries)) { + /** @var array}> */ + return $boundaries; + } + + // Indexed format — convert to keyed + $result = []; + + foreach ($boundaries as $boundary) { + if (! is_array($boundary) || ! isset($boundary['scope'])) { + continue; + } + + /** @var string $scopeKey */ + $scopeKey = $boundary['scope']; + /** @var array $maxPerms */ + $maxPerms = $boundary['max_permissions'] ?? []; + $result[$scopeKey] = [ + 'max_permissions' => $maxPerms, + ]; + } + + return $result; + } + /** * @param array $errors */ @@ -96,6 +326,8 @@ private function validatePermissions(mixed $permissions, array &$errors): void } /** + * Validate indexed roles (array of objects with id, name, permissions). + * * @param array $errors */ private function validateRoles(mixed $roles, array &$errors): void @@ -141,6 +373,39 @@ private function validateRoles(mixed $roles, array &$errors): void } } + /** + * Validate keyed roles (associative: id => {permissions, conditions?}). + * + * @param array $roles + * @param array $errors + */ + private function validateKeyedRoles(array $roles, array &$errors): void + { + foreach ($roles as $roleId => $roleData) { + if (! is_array($roleData)) { + $errors[] = "roles[{$roleId}] must be an object"; + + continue; + } + + if (! array_key_exists('permissions', $roleData)) { + $errors[] = "roles[{$roleId}] is missing required key: permissions"; + + continue; + } + + if (! is_array($roleData['permissions'])) { + $errors[] = "roles[{$roleId}].permissions must be an array"; + } else { + foreach ($roleData['permissions'] as $pIndex => $permission) { + if (! is_string($permission)) { + $errors[] = "roles[{$roleId}].permissions[{$pIndex}] must be a string"; + } + } + } + } + } + /** * @param array $errors */ @@ -176,6 +441,8 @@ private function validateAssignments(mixed $assignments, array &$errors): void } /** + * Validate indexed boundaries (array of objects with scope, max_permissions). + * * @param array $errors */ private function validateBoundaries(mixed $boundaries, array &$errors): void @@ -216,4 +483,65 @@ private function validateBoundaries(mixed $boundaries, array &$errors): void } } } + + /** + * Validate keyed boundaries (associative: scope => {max_permissions}). + * + * @param array $boundaries + * @param array $errors + */ + private function validateKeyedBoundaries(array $boundaries, array &$errors): void + { + foreach ($boundaries as $scope => $boundaryData) { + if (! is_array($boundaryData)) { + $errors[] = "boundaries[{$scope}] must be an object"; + + continue; + } + + if (! array_key_exists('max_permissions', $boundaryData)) { + $errors[] = "boundaries[{$scope}] is missing required key: max_permissions"; + + continue; + } + + if (! is_array($boundaryData['max_permissions'])) { + $errors[] = "boundaries[{$scope}].max_permissions must be an array"; + } else { + foreach ($boundaryData['max_permissions'] as $pIndex => $permission) { + if (! is_string($permission)) { + $errors[] = "boundaries[{$scope}].max_permissions[{$pIndex}] must be a string"; + } + } + } + } + } + + /** + * Validate resource_policies array. + * + * @param array $errors + */ + private function validateResourcePolicies(mixed $resourcePolicies, array &$errors): void + { + if (! is_array($resourcePolicies)) { + $errors[] = 'resource_policies must be an array'; + + return; + } + + foreach ($resourcePolicies as $index => $entry) { + if (! is_array($entry)) { + $errors[] = "resource_policies[{$index}] must be an object"; + + continue; + } + + foreach (['resource_type', 'effect', 'action'] as $key) { + if (! array_key_exists($key, $entry)) { + $errors[] = "resource_policies[{$index}] is missing required key: {$key}"; + } + } + } + } } diff --git a/src/Enums/Decision.php b/src/Enums/Decision.php new file mode 100644 index 0000000..dd2c9fb --- /dev/null +++ b/src/Enums/Decision.php @@ -0,0 +1,11 @@ +inner->can($subjectType, $subjectId, $permission); + return $this->inner->evaluate($request); } - $generation = CacheStoreResolver::subjectGeneration($this->cache, $subjectType, $subjectId); - $globalGen = CacheStoreResolver::globalGeneration($this->cache); - $cacheKey = self::key('can', $subjectType, $subjectId, $permission, $generation, $globalGen); - $store = CacheStoreResolver::forSubject($this->cache, $subjectType, $subjectId); - - /* - * Race window (TOCTOU): Between the cache miss and the put() below, - * another request may revoke a role and clear the cache. This request - * would then write a stale "allowed" result that persists for the full - * TTL. This is inherent to cache-aside patterns. Mitigations: - * - Use a shorter TTL for security-critical applications - * - Use a dedicated cache store to isolate invalidation - * - See "Customizing the Cache" docs for advanced strategies - * - * Values are wrapped in an array (['v' => bool]) so that a stored - * `false` is never confused with a cache miss (`null`). Some cache - * drivers cannot distinguish stored `false` from a miss. - */ - $result = $store->get($cacheKey); - - if (is_array($result)) { - /** @var bool */ - return $result['v']; - } - - $evaluated = $this->inner->can($subjectType, $subjectId, $permission); - $store->put($cacheKey, ['v' => $evaluated], $this->ttl()); - - return $evaluated; - } - - public function explain(string $subjectType, string|int $subjectId, string $permission): EvaluationTrace - { - return $this->inner->explain($subjectType, $subjectId, $permission); - } - - /** - * @return array - */ - public function effectivePermissions(string $subjectType, string|int $subjectId, ?string $scope = null): array - { - if (! config('policy-engine.cache.enabled')) { - return $this->inner->effectivePermissions($subjectType, $subjectId, $scope); - } - - $generation = CacheStoreResolver::subjectGeneration($this->cache, $subjectType, $subjectId); - $globalGen = CacheStoreResolver::globalGeneration($this->cache); - $cacheKey = self::key('effective', $subjectType, $subjectId, $scope, $generation, $globalGen); - $store = CacheStoreResolver::forSubject($this->cache, $subjectType, $subjectId); - - $result = $store->get($cacheKey); + $principal = $request->principal; + $generation = CacheStoreResolver::subjectGeneration($this->cache, $principal->type, (string) $principal->id); + $globalGeneration = CacheStoreResolver::globalGeneration($this->cache); + $store = CacheStoreResolver::forSubject($this->cache, $principal->type, (string) $principal->id); + $cacheKey = self::key($request, $generation, $globalGeneration); - if (is_array($result) && array_key_exists('v', $result)) { - /** @var array */ - return $result['v']; - } - - $evaluated = $this->inner->effectivePermissions($subjectType, $subjectId, $scope); - $store->put($cacheKey, ['v' => $evaluated], $this->ttl()); - - return $evaluated; - } + $cached = $store->get($cacheKey); - public function hasRole(string $subjectType, string|int $subjectId, string $role, ?string $scope = null): bool - { - if (! config('policy-engine.cache.enabled')) { - return $this->inner->hasRole($subjectType, $subjectId, $role, $scope); + if (is_array($cached) && is_string($cached['d'] ?? null) && is_string($cached['b'] ?? null)) { + return new EvaluationResult( + decision: Decision::from($cached['d']), + decidedBy: $cached['b'], + ); } - $generation = CacheStoreResolver::subjectGeneration($this->cache, $subjectType, $subjectId); - $globalGen = CacheStoreResolver::globalGeneration($this->cache); - $cacheKey = self::key('role', $subjectType, $subjectId, $role.($scope !== null ? ":{$scope}" : ''), $generation, $globalGen); - $store = CacheStoreResolver::forSubject($this->cache, $subjectType, $subjectId); + $result = $this->inner->evaluate($request); - $result = $store->get($cacheKey); - - if (is_array($result)) { - /** @var bool */ - return $result['v']; - } + $store->put($cacheKey, ['d' => $result->decision->name, 'b' => $result->decidedBy], $this->ttl()); - $evaluated = $this->inner->hasRole($subjectType, $subjectId, $role, $scope); - $store->put($cacheKey, ['v' => $evaluated], $this->ttl()); - - return $evaluated; + return $result; } /** - * Build a namespaced cache key. + * Build a namespaced cache key for an EvaluationRequest. * - * Format: policy-engine:{type}:{subjectType}:{subjectId}[:g{generation}]:{hashedSuffix} - * The type prefix (can, role, effective) prevents collisions between - * different cache entry kinds. The suffix is hashed with MD5 to ensure - * safe key lengths across all cache drivers (Memcached 250-char limit, - * file-based drivers that use the key as a filename, etc.). + * Format: policy-engine:eval:{principalType}:{principalId}[:g{combinedGen}]:{md5hash} + * The hash encodes the full request identity (principal, action, resource, scope) + * to prevent collisions across different request shapes sharing the same subject. * * The generation segment combines global and per-subject generations on * non-tagged stores. Global generation is incremented by flush() (role/ @@ -124,9 +59,10 @@ public function hasRole(string $subjectType, string|int $subjectId, string $role * flushSubject() (assignment changes). Both must be embedded so either * invalidation path renders old keys unreachable. */ - public static function key(string $type, string $subjectType, string|int $subjectId, ?string $suffix = null, int $generation = 0, int $globalGeneration = 0): string + public static function key(EvaluationRequest $request, int $generation = 0, int $globalGeneration = 0): string { - $key = "policy-engine:{$type}:{$subjectType}:{$subjectId}"; + $principal = $request->principal; + $key = "policy-engine:eval:{$principal->type}:{$principal->id}"; $combinedGeneration = $globalGeneration + $generation; @@ -134,19 +70,16 @@ public static function key(string $type, string $subjectType, string|int $subjec $key .= ":g{$combinedGeneration}"; } - if ($suffix !== null) { - $key .= ':'.md5($suffix); - } - - return $key; - } + $hash = md5(serialize([ + $principal->type, + $principal->id, + $request->action, + $request->resource?->type, + $request->resource?->id, + $request->context->scope, + ])); - /** - * @deprecated Use key() instead. Kept for backward compatibility. - */ - public static function cacheKey(string $subjectType, string|int $subjectId, ?string $permission = null): string - { - return self::key('can', $subjectType, $subjectId, $permission); + return "{$key}:{$hash}"; } private function ttl(): int diff --git a/src/Evaluators/DefaultEvaluator.php b/src/Evaluators/DefaultEvaluator.php index 5810b85..c194391 100644 --- a/src/Evaluators/DefaultEvaluator.php +++ b/src/Evaluators/DefaultEvaluator.php @@ -4,444 +4,132 @@ namespace DynamikDev\PolicyEngine\Evaluators; -use DynamikDev\PolicyEngine\Contracts\AssignmentStore; -use DynamikDev\PolicyEngine\Contracts\BoundaryStore; +use DynamikDev\PolicyEngine\Contracts\ConditionRegistry; use DynamikDev\PolicyEngine\Contracts\Evaluator; use DynamikDev\PolicyEngine\Contracts\Matcher; -use DynamikDev\PolicyEngine\Contracts\RoleStore; -use DynamikDev\PolicyEngine\DTOs\EvaluationTrace; -use DynamikDev\PolicyEngine\Enums\EvaluationResult; -use DynamikDev\PolicyEngine\Events\AuthorizationDenied; -use DynamikDev\PolicyEngine\Models\Assignment; +use DynamikDev\PolicyEngine\Contracts\PolicyResolver; +use DynamikDev\PolicyEngine\DTOs\EvaluationRequest; +use DynamikDev\PolicyEngine\DTOs\EvaluationResult; +use DynamikDev\PolicyEngine\DTOs\PolicyStatement; +use DynamikDev\PolicyEngine\DTOs\Principal; +use DynamikDev\PolicyEngine\DTOs\Resource; +use DynamikDev\PolicyEngine\Enums\Decision; +use DynamikDev\PolicyEngine\Enums\Effect; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Event; -use Laravel\Sanctum\PersonalAccessToken; class DefaultEvaluator implements Evaluator { + /** + * @param PolicyResolver[] $resolvers + */ public function __construct( - private readonly AssignmentStore $assignments, - private readonly RoleStore $roles, - private readonly BoundaryStore $boundaries, + private readonly array $resolvers, private readonly Matcher $matcher, + private readonly ?ConditionRegistry $conditionRegistry = null, ) {} - public function can(string $subjectType, string|int $subjectId, string $permission): bool - { - $trace = $this->evaluate($subjectType, $subjectId, $permission); - - if ($trace->result === EvaluationResult::Deny) { - [$requiredPermission, $scope] = $this->parseScope($permission); - $this->dispatchDenialIfEnabled($subjectType, $subjectId, $requiredPermission, $scope); - } - - return $trace->result === EvaluationResult::Allow; - } - - /** - * Evaluate a permission and return a detailed trace of the decision. - * - * @throws \RuntimeException If explain mode is disabled in config. - */ - public function explain(string $subjectType, string|int $subjectId, string $permission): EvaluationTrace - { - if (! config('policy-engine.explain')) { - throw new \RuntimeException('Explain mode is disabled. Set policy-engine.explain to true.'); - } - - return $this->evaluate($subjectType, $subjectId, $permission); - } - - public function hasRole(string $subjectType, string|int $subjectId, string $role, ?string $scope = null): bool - { - if ($scope !== null) { - return $this->assignments->forSubjectInScope($subjectType, $subjectId, $scope) - ->contains('role_id', $role); - } - - return $this->assignments->forSubjectGlobal($subjectType, $subjectId) - ->contains('role_id', $role); - } - - /** - * Collect all effective permissions for a subject, optionally within a scope. - * - * @return array - */ - public function effectivePermissions(string $subjectType, string|int $subjectId, ?string $scope = null): array - { - $allAssignments = $this->gatherAssignments($subjectType, $subjectId, $scope); - - $allows = []; - $denies = []; - $permissionsByRole = $this->permissionsByRole($allAssignments); - - foreach ($permissionsByRole as $permissions) { - foreach ($permissions as $permission) { - if (str_starts_with($permission, '!')) { - $denies[] = substr($permission, 1); - } else { - $allows[] = $permission; - } - } - } - - $boundaryMaxPermissions = $this->resolveBoundaryMaxPermissions($scope); - - // Remove any allowed permission that is matched by a deny rule or blocked by a boundary. - return array_values(array_filter( - array_unique($allows), - fn (string $allow): bool => ! $this->isDenied($allow, $denies) - && $this->passesResolvedBoundary($boundaryMaxPermissions, $allow), - )); - } - - /** - * Parse an optional scope suffix from a permission string. - * - * Format: `permission:scope` (e.g., `posts.create:group::5`). - * The first colon separates permission from scope. The scope itself - * may contain colons (e.g., `group::5`). - * - * @return array{0: string, 1: ?string} - */ - private function parseScope(string $permission): array - { - $colonPos = strpos($permission, ':'); - - if ($colonPos === false) { - return [$permission, null]; - } - - return [ - substr($permission, 0, $colonPos), - substr($permission, $colonPos + 1), - ]; - } - - /** - * Gather all relevant assignments for a subject. - * - * If a scope is present, returns both global (unscoped) assignments - * and assignments specific to that scope. Otherwise, returns only - * global assignments. - * - * @return Collection - */ - private function gatherAssignments(string $subjectType, string|int $subjectId, ?string $scope): Collection + public function evaluate(EvaluationRequest $request): EvaluationResult { - if ($scope === null) { - return $this->assignments->forSubjectGlobal($subjectType, $subjectId); - } + $applicable = $this->collectApplicableStatements($request); - return $this->assignments->forSubjectGlobalAndScope($subjectType, $subjectId, $scope); - } + $traceEnabled = (bool) config('policy-engine.trace'); + $matchedStatements = $traceEnabled ? $applicable->all() : []; - /** - * Run the full evaluation pipeline and return a trace of the decision. - */ - private function evaluate(string $subjectType, string|int $subjectId, string $permission): EvaluationTrace - { - [$requiredPermission, $scope] = $this->parseScope($permission); - $subject = $subjectType.':'.$subjectId; + $deny = $applicable->first(fn (PolicyStatement $s): bool => $s->effect === Effect::Deny); - $allAssignments = $this->gatherAssignments($subjectType, $subjectId, $scope); - - if ($allAssignments->isEmpty()) { - return new EvaluationTrace( - subject: $subject, - required: $requiredPermission, - result: EvaluationResult::Deny, - assignments: [], - boundary: null, - cacheHit: false, + if ($deny !== null) { + return new EvaluationResult( + decision: Decision::Deny, + decidedBy: $deny->source, + matchedStatements: $matchedStatements, ); } - [$passesBoundary, $boundaryNote] = $this->evaluateBoundary($scope, $requiredPermission); + $allow = $applicable->first(fn (PolicyStatement $s): bool => $s->effect === Effect::Allow); - if (! $passesBoundary) { - return new EvaluationTrace( - subject: $subject, - required: $requiredPermission, - result: EvaluationResult::Deny, - assignments: [], - boundary: $boundaryNote, - cacheHit: false, + if ($allow !== null) { + return new EvaluationResult( + decision: Decision::Allow, + decidedBy: $allow->source, + matchedStatements: $matchedStatements, ); } - $permissionsByRole = $this->permissionsByRole($allAssignments); - - $traceAssignments = []; - foreach ($allAssignments as $assignment) { - $traceAssignments[] = [ - 'role' => $assignment->role_id, - 'scope' => $assignment->scope, - 'permissions_checked' => $permissionsByRole[$assignment->role_id] ?? [], - ]; - } - - $allows = []; - $denies = []; - - foreach ($permissionsByRole as $permissions) { - foreach ($permissions as $perm) { - if (str_starts_with($perm, '!')) { - $denies[] = $perm; - } else { - $allows[] = $perm; - } - } - } - - foreach ($denies as $deny) { - if ($this->matcher->matches(substr($deny, 1), $requiredPermission)) { - return new EvaluationTrace( - subject: $subject, - required: $requiredPermission, - result: EvaluationResult::Deny, - assignments: $traceAssignments, - boundary: $boundaryNote, - cacheHit: false, - ); - } - } - - $result = EvaluationResult::Deny; - foreach ($allows as $allow) { - if ($this->matcher->matches($allow, $requiredPermission)) { - $result = EvaluationResult::Allow; - break; - } - } - - $sanctumNote = null; - - if ($result === EvaluationResult::Allow && ! $this->sanctumTokenAllows($subjectType, $subjectId, $requiredPermission)) { - $result = EvaluationResult::Deny; - $sanctumNote = 'Denied by Sanctum token ability restriction'; - } - - return new EvaluationTrace( - subject: $subject, - required: $requiredPermission, - result: $result, - assignments: $traceAssignments, - boundary: $boundaryNote, - cacheHit: false, - sanctum: $sanctumNote, + return new EvaluationResult( + decision: Decision::Deny, + decidedBy: 'default-deny', + matchedStatements: [], ); } /** - * Evaluate boundary constraints and return [passes, note]. + * Collect all statements from all resolvers and filter to those applicable to the request. * - * @return array{0: bool, 1: ?string} + * @return Collection */ - private function evaluateBoundary(?string $scope, string $requiredPermission): array + private function collectApplicableStatements(EvaluationRequest $request): Collection { - if ($scope !== null) { - $boundary = $this->boundaries->find($scope); - - if ($boundary !== null) { - $passes = $this->matchesAny($boundary->max_permissions, $requiredPermission); - - return [ - $passes, - $passes - ? "Passed boundary on scope [{$scope}]" - : "Denied by boundary on scope [{$scope}]", - ]; - } + $all = collect(); - if (config('policy-engine.deny_unbounded_scopes')) { - return [false, "Denied by missing boundary on scope [{$scope}] (deny_unbounded_scopes enabled)"]; - } - - return [true, null]; - } - - if (! config('policy-engine.enforce_boundaries_on_global')) { - return [true, null]; - } - - $allBoundaries = $this->boundaries->all(); - - if ($allBoundaries->isEmpty()) { - return [true, null]; - } - - foreach ($allBoundaries as $boundary) { - if ($this->matchesAny($boundary->max_permissions, $requiredPermission)) { - return [true, 'Passed global boundary enforcement']; - } + foreach ($this->resolvers as $resolver) { + $all = $all->merge($resolver->resolve($request)); } - return [false, 'Denied by global boundary enforcement (enforce_boundaries_on_global enabled)']; + return $all->filter(fn (PolicyStatement $statement): bool => $this->matchesAction($statement, $request) + && $this->matchesPrincipal($statement, $request->principal) + && $this->matchesResource($statement, $request->resource) + && $this->conditionsPass($statement, $request) + )->values(); } - /** - * Pre-fetch boundary max_permissions for a scope, returning null if no boundary applies. - * - * Returns null when boundaries don't restrict (no boundary check needed), - * an empty array when boundaries block everything, or the max_permissions arrays to match against. - * - * @return array>|null - */ - private function resolveBoundaryMaxPermissions(?string $scope): ?array + private function matchesAction(PolicyStatement $statement, EvaluationRequest $request): bool { - if ($scope === null) { - if (! config('policy-engine.enforce_boundaries_on_global')) { - return null; - } - - $allBoundaries = $this->boundaries->all(); - - if ($allBoundaries->isEmpty()) { - return null; - } - - return $allBoundaries->map(fn ($b): array => $b->max_permissions)->values()->all(); - } - - $boundary = $this->boundaries->find($scope); - - if ($boundary !== null) { - return [$boundary->max_permissions]; - } - - // No boundary defined for this scope — deny everything if deny_unbounded_scopes is enabled. - return config('policy-engine.deny_unbounded_scopes') ? [[]] : null; + return $this->matcher->matches($statement->action, $request->action); } - /** - * Check a permission against pre-resolved boundary max_permissions. - * - * @param array>|null $boundaryGroups null means no restriction. - */ - private function passesResolvedBoundary(?array $boundaryGroups, string $permission): bool + private function matchesPrincipal(PolicyStatement $statement, Principal $principal): bool { - if ($boundaryGroups === null) { + if ($statement->principalPattern === null) { return true; } - foreach ($boundaryGroups as $maxPermissions) { - if ($this->matchesAny($maxPermissions, $permission)) { - return true; - } - } - - return false; - } - - /** - * Resolve permissions per role with optional batched loading. - * - * @param Collection $assignments - * @return array> - */ - private function permissionsByRole(Collection $assignments): array - { - /** @var array $roleIds */ - $roleIds = $assignments->pluck('role_id')->unique()->values()->all(); - - if ($roleIds === []) { - return []; - } - - return $this->roles->permissionsForRoles($roleIds); - } - - /** - * Check whether any pattern in the given list matches the required permission. - * - * @param array $patterns - */ - private function matchesAny(array $patterns, string $required): bool - { - foreach ($patterns as $pattern) { - if ($this->matcher->matches($pattern, $required)) { - return true; - } - } - - return false; - } - - /** - * Check whether an allowed permission is denied by any deny rule. - * - * @param array $denies Deny patterns (already stripped of `!` prefix). - */ - private function isDenied(string $allow, array $denies): bool - { - foreach ($denies as $deny) { - if ($this->matcher->matches($deny, $allow)) { - return true; - } + if ($statement->principalPattern === '*') { + return true; } - return false; + return $statement->principalPattern === "{$principal->type}:{$principal->id}"; } - /** - * Check whether the current Sanctum token (if any) allows the required permission. - * - * Returns true if there is no Sanctum token (session auth) or if the token's - * abilities include a matching permission. Returns false only when a Sanctum - * token is present but does not grant the required permission. - */ - private function sanctumTokenAllows(string $subjectType, string|int $subjectId, string $requiredPermission): bool + private function matchesResource(PolicyStatement $statement, ?Resource $resource): bool { - if (! class_exists(PersonalAccessToken::class)) { - return true; - } - - $user = auth()->user(); - - if ($user === null) { + if ($statement->resourcePattern === null) { return true; } - // Only apply token scoping if the authenticated user matches the subject being evaluated. - /** @var int|string $userKey */ - $userKey = $user->getKey(); - - if ($user->getMorphClass() !== $subjectType || (string) $userKey !== (string) $subjectId) { + if ($statement->resourcePattern === '*') { return true; } - if (! method_exists($user, 'currentAccessToken')) { - return true; + if ($resource === null) { + return false; } - $token = $user->currentAccessToken(); + return $statement->resourcePattern === "{$resource->type}:{$resource->id}"; + } - if (! $token instanceof PersonalAccessToken) { + private function conditionsPass(PolicyStatement $statement, EvaluationRequest $request): bool + { + if ($statement->conditions === [] || $this->conditionRegistry === null) { return true; } - /** @var array $abilities */ - $abilities = $token->abilities; // @phpstan-ignore property.notFound (Sanctum model lacks @property PHPDoc) - - foreach ($abilities as $ability) { - if ($ability === '*' || $this->matcher->matches($ability, $requiredPermission)) { - return true; + foreach ($statement->conditions as $condition) { + $evaluator = $this->conditionRegistry->evaluatorFor($condition->type); + if (! $evaluator->passes($condition, $request)) { + return false; } } - return false; - } - - private function dispatchDenialIfEnabled(string $subjectType, string|int $subjectId, string $permission, ?string $scope): void - { - if (config('policy-engine.log_denials')) { - Event::dispatch(new AuthorizationDenied( - subject: $subjectType.':'.$subjectId, - permission: $permission, - scope: $scope, - )); - } + return true; } } diff --git a/src/Models/ResourcePolicy.php b/src/Models/ResourcePolicy.php new file mode 100644 index 0000000..ead4a08 --- /dev/null +++ b/src/Models/ResourcePolicy.php @@ -0,0 +1,50 @@ +|null $conditions + */ +class ResourcePolicy extends Model +{ + use HasUlids; + + /** @var list */ + protected $guarded = []; + + public function getTable(): string + { + /** @var string $prefix */ + $prefix = config('policy-engine.table_prefix', ''); + + return $prefix.'resource_policies'; + } + + public function getEffectEnum(): Effect + { + /** @var Effect */ + return constant(Effect::class.'::'.$this->effect); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'conditions' => 'array', + ]; + } +} diff --git a/src/PolicyEngineServiceProvider.php b/src/PolicyEngineServiceProvider.php index 391b4f9..52474db 100644 --- a/src/PolicyEngineServiceProvider.php +++ b/src/PolicyEngineServiceProvider.php @@ -4,19 +4,29 @@ namespace DynamikDev\PolicyEngine; +use DynamikDev\PolicyEngine\Conditions\AttributeEqualsEvaluator; +use DynamikDev\PolicyEngine\Conditions\AttributeInEvaluator; +use DynamikDev\PolicyEngine\Conditions\DefaultConditionRegistry; +use DynamikDev\PolicyEngine\Conditions\EnvironmentEqualsEvaluator; +use DynamikDev\PolicyEngine\Conditions\IpRangeEvaluator; +use DynamikDev\PolicyEngine\Conditions\TimeBetweenEvaluator; use DynamikDev\PolicyEngine\Contracts\AssignmentStore; use DynamikDev\PolicyEngine\Contracts\BoundaryStore; +use DynamikDev\PolicyEngine\Contracts\ConditionRegistry; use DynamikDev\PolicyEngine\Contracts\DocumentExporter; use DynamikDev\PolicyEngine\Contracts\DocumentImporter; use DynamikDev\PolicyEngine\Contracts\DocumentParser; use DynamikDev\PolicyEngine\Contracts\Evaluator; use DynamikDev\PolicyEngine\Contracts\Matcher; use DynamikDev\PolicyEngine\Contracts\PermissionStore; +use DynamikDev\PolicyEngine\Contracts\PolicyResolver; +use DynamikDev\PolicyEngine\Contracts\ResourcePolicyStore; use DynamikDev\PolicyEngine\Contracts\RoleStore; use DynamikDev\PolicyEngine\Contracts\ScopeResolver; use DynamikDev\PolicyEngine\Documents\DefaultDocumentExporter; use DynamikDev\PolicyEngine\Documents\DefaultDocumentImporter; use DynamikDev\PolicyEngine\Documents\JsonDocumentParser; +use DynamikDev\PolicyEngine\DTOs\Resource; use DynamikDev\PolicyEngine\Evaluators\CachedEvaluator; use DynamikDev\PolicyEngine\Evaluators\DefaultEvaluator; use DynamikDev\PolicyEngine\Events\AssignmentCreated; @@ -31,11 +41,14 @@ use DynamikDev\PolicyEngine\Listeners\InvalidatePermissionCache; use DynamikDev\PolicyEngine\Matchers\WildcardMatcher; use DynamikDev\PolicyEngine\Middleware\RoleMiddleware; +use DynamikDev\PolicyEngine\Resolvers\BoundaryPolicyResolver; use DynamikDev\PolicyEngine\Resolvers\ModelScopeResolver; +use DynamikDev\PolicyEngine\Resolvers\SanctumPolicyResolver; use DynamikDev\PolicyEngine\Stores\CachingBoundaryStore; use DynamikDev\PolicyEngine\Stores\EloquentAssignmentStore; use DynamikDev\PolicyEngine\Stores\EloquentBoundaryStore; use DynamikDev\PolicyEngine\Stores\EloquentPermissionStore; +use DynamikDev\PolicyEngine\Stores\EloquentResourcePolicyStore; use DynamikDev\PolicyEngine\Stores\EloquentRoleStore; use DynamikDev\PolicyEngine\Support\CacheStoreResolver; use Illuminate\Cache\CacheManager; @@ -60,22 +73,57 @@ public function register(): void $this->app->singleton(RoleStore::class, EloquentRoleStore::class); $this->app->singleton(AssignmentStore::class, EloquentAssignmentStore::class); $this->app->singleton(BoundaryStore::class, EloquentBoundaryStore::class); + $this->app->singleton(ResourcePolicyStore::class, EloquentResourcePolicyStore::class); $this->app->singleton(Matcher::class, WildcardMatcher::class); $this->app->singleton(ScopeResolver::class, ModelScopeResolver::class); $this->app->singleton(DocumentParser::class, JsonDocumentParser::class); $this->app->singleton(DocumentImporter::class, DefaultDocumentImporter::class); $this->app->singleton(DocumentExporter::class, DefaultDocumentExporter::class); + $this->app->singleton(ConditionRegistry::class, function (): DefaultConditionRegistry { + $registry = new DefaultConditionRegistry; + $registry->register('attribute_equals', AttributeEqualsEvaluator::class); + $registry->register('attribute_in', AttributeInEvaluator::class); + $registry->register('environment_equals', EnvironmentEqualsEvaluator::class); + $registry->register('ip_range', IpRangeEvaluator::class); + $registry->register('time_between', TimeBetweenEvaluator::class); + + return $registry; + }); + + $this->app->singleton(SanctumPolicyResolver::class, function ($app): SanctumPolicyResolver { + return new SanctumPolicyResolver( + matcher: $app->make(Matcher::class), + permissionStore: $app->make(PermissionStore::class), + ); + }); + + $this->app->singleton(BoundaryPolicyResolver::class, function ($app) { + return new BoundaryPolicyResolver( + boundaries: new CachingBoundaryStore( + inner: $app->make(BoundaryStore::class), + cache: $app->make(CacheManager::class), + ), + matcher: $app->make(Matcher::class), + permissionStore: $app->make(PermissionStore::class), + denyUnboundedScopes: (bool) config('policy-engine.deny_unbounded_scopes', false), + enforceOnGlobal: (bool) config('policy-engine.enforce_boundaries_on_global', false), + ); + }); + $this->app->singleton(Evaluator::class, function ($app): CachedEvaluator { + /** @var array $resolverClasses */ + $resolverClasses = config('policy-engine.resolvers', []); + $resolvers = array_map( + fn (string $class): PolicyResolver => $app->make($class), + $resolverClasses, + ); + return new CachedEvaluator( inner: new DefaultEvaluator( - assignments: $app->make(AssignmentStore::class), - roles: $app->make(RoleStore::class), - boundaries: new CachingBoundaryStore( - inner: $app->make(BoundaryStore::class), - cache: $app->make(CacheManager::class), - ), + resolvers: $resolvers, matcher: $app->make(Matcher::class), + conditionRegistry: $app->make(ConditionRegistry::class), ), cache: $app->make(CacheManager::class), ); @@ -133,18 +181,12 @@ private function registerBladeDirectives(): void private function registerGateHook(): void { Gate::before(static function (Authenticatable $user, string $ability, array $arguments): ?bool { - /* - * Non-dot abilities (update, delete, view) are left to Laravel's - * standard Gate and Policy resolution. Policies can call canDo() - * internally when they need the engine. - */ if (! str_contains($ability, '.')) { return null; } /** @var array $passthrough */ $passthrough = config('policy-engine.gate_passthrough', []); - if (in_array($ability, $passthrough, true)) { return null; } @@ -153,13 +195,45 @@ private function registerGateHook(): void return null; } - // Dot-notated abilities are always handled by the engine. - $scope = $arguments[0] ?? null; + [$scope, $resource] = self::resolveGateArguments($arguments); - return $user->canDo($ability, $scope); + return $user->canDo($ability, $scope, $resource); }); } + /** + * @param array $arguments + * @return array{0: mixed, 1: ?resource} + */ + private static function resolveGateArguments(array $arguments): array + { + $first = $arguments[0] ?? null; + $second = $arguments[1] ?? null; + + if ($first === null) { + return [null, null]; + } + + if ($second === null) { + if ($first instanceof Resource) { + return [null, $first]; + } + if (is_object($first) && method_exists($first, 'toPolicyResource')) { + return [null, $first->toPolicyResource()]; + } + + return [$first, null]; + } + + $resource = match (true) { + $second instanceof Resource => $second, + is_object($second) && method_exists($second, 'toPolicyResource') => $second->toPolicyResource(), + default => null, + }; + + return [$first, $resource]; + } + private function registerEventListeners(): void { Event::listen(AssignmentCreated::class, InvalidatePermissionCache::class); diff --git a/src/Resolvers/BoundaryPolicyResolver.php b/src/Resolvers/BoundaryPolicyResolver.php new file mode 100644 index 0000000..9c5c7f7 --- /dev/null +++ b/src/Resolvers/BoundaryPolicyResolver.php @@ -0,0 +1,137 @@ + + */ + public function resolve(EvaluationRequest $request): Collection + { + $scope = $request->context->scope; + + if ($scope === null) { + return $this->resolveGlobal(); + } + + return $this->resolveScoped($scope); + } + + /** + * @return Collection + */ + private function resolveGlobal(): Collection + { + if (! $this->enforceOnGlobal) { + return collect(); + } + + $allBoundaries = $this->boundaries->all(); + $ceilingPatterns = $allBoundaries + ->flatMap(fn ($boundary) => $boundary->max_permissions) + ->unique() + ->values() + ->all(); + + return $this->denyPermissionsOutsideCeiling($ceilingPatterns, 'boundary:global'); + } + + /** + * @return Collection + */ + private function resolveScoped(string $scope): Collection + { + $boundary = $this->boundaries->find($scope); + + if ($boundary !== null) { + return $this->denyPermissionsOutsideCeiling( + ceilingPatterns: $boundary->max_permissions, + source: "boundary:{$scope}", + ); + } + + if ($this->denyUnboundedScopes) { + return $this->denyAllPermissions("boundary:unbounded:{$scope}"); + } + + return collect(); + } + + /** + * Produce Deny statements for all registered permissions that do not match + * any of the given ceiling patterns. + * + * @param array $ceilingPatterns + * @return Collection + */ + private function denyPermissionsOutsideCeiling(array $ceilingPatterns, string $source): Collection + { + return $this->permissionStore->all() + ->reject(fn (Permission $permission) => $this->matchesCeiling($permission->id, $ceilingPatterns)) + ->map(fn (Permission $permission) => new PolicyStatement( + effect: Effect::Deny, + action: $permission->id, + principalPattern: null, + resourcePattern: null, + conditions: [], + source: $source, + )) + ->values(); + } + + /** + * Produce Deny statements for ALL registered permissions. + * + * @return Collection + */ + private function denyAllPermissions(string $source): Collection + { + return $this->permissionStore->all() + ->map(fn (Permission $permission) => new PolicyStatement( + effect: Effect::Deny, + action: $permission->id, + principalPattern: null, + resourcePattern: null, + conditions: [], + source: $source, + )) + ->values(); + } + + /** + * Check whether the given permission matches any of the ceiling patterns. + * + * @param array $ceilingPatterns + */ + private function matchesCeiling(string $permissionId, array $ceilingPatterns): bool + { + foreach ($ceilingPatterns as $pattern) { + if ($this->matcher->matches($pattern, $permissionId)) { + return true; + } + } + + return false; + } +} diff --git a/src/Resolvers/IdentityPolicyResolver.php b/src/Resolvers/IdentityPolicyResolver.php new file mode 100644 index 0000000..21b31ae --- /dev/null +++ b/src/Resolvers/IdentityPolicyResolver.php @@ -0,0 +1,67 @@ + + */ + public function resolve(EvaluationRequest $request): Collection + { + $scope = $request->context->scope; + + $assignments = $scope === null + ? $this->assignments->forSubjectGlobal($request->principal->type, $request->principal->id) + : $this->assignments->forSubjectGlobalAndScope($request->principal->type, $request->principal->id, $scope); + + if ($assignments->isEmpty()) { + return collect(); + } + + /** @var array $roleIds */ + $roleIds = $assignments->pluck('role_id')->unique()->values()->all(); + $permissionsByRole = $this->roles->permissionsForRoles($roleIds); + + $statements = collect(); + + foreach ($assignments as $assignment) { + /** @var Assignment $assignment */ + $roleId = $assignment->role_id; + $permissions = $permissionsByRole[$roleId] ?? []; + + foreach ($permissions as $permission) { + $isDeny = str_starts_with($permission, '!'); + $action = $isDeny ? substr($permission, 1) : $permission; + $effect = $isDeny ? Effect::Deny : Effect::Allow; + + $statements->push(new PolicyStatement( + effect: $effect, + action: $action, + principalPattern: null, + resourcePattern: null, + conditions: [], + source: "role:{$roleId}", + )); + } + } + + return $statements; + } +} diff --git a/src/Resolvers/ResourcePolicyResolver.php b/src/Resolvers/ResourcePolicyResolver.php new file mode 100644 index 0000000..6a59521 --- /dev/null +++ b/src/Resolvers/ResourcePolicyResolver.php @@ -0,0 +1,30 @@ + + */ + public function resolve(EvaluationRequest $request): Collection + { + if ($request->resource === null) { + return collect(); + } + + return $this->store->forResource($request->resource->type, $request->resource->id); + } +} diff --git a/src/Resolvers/SanctumPolicyResolver.php b/src/Resolvers/SanctumPolicyResolver.php new file mode 100644 index 0000000..482bda6 --- /dev/null +++ b/src/Resolvers/SanctumPolicyResolver.php @@ -0,0 +1,93 @@ + + */ + public function resolve(EvaluationRequest $request): Collection + { + if (! class_exists(PersonalAccessToken::class)) { + return collect(); + } + + $user = auth()->user(); + + if ($user === null) { + return collect(); + } + + /** @var string|int $authId */ + $authId = $user->getAuthIdentifier(); + + if ( + $user->getMorphClass() !== $request->principal->type + || (string) $authId !== (string) $request->principal->id + ) { + return collect(); + } + + if (! method_exists($user, 'currentAccessToken')) { + return collect(); + } + + $token = $user->currentAccessToken(); + + if (! $token instanceof PersonalAccessToken) { + return collect(); + } + + $abilities = $token->abilities ?? []; + + if (in_array('*', $abilities, true)) { + return collect(); + } + + return $this->permissionStore->all() + ->reject(fn (Permission $permission) => $this->matchesAnyAbility($permission->id, $abilities)) + ->map(fn (Permission $permission) => new PolicyStatement( + effect: Effect::Deny, + action: $permission->id, + principalPattern: null, + resourcePattern: null, + conditions: [], + source: 'sanctum-token', + )) + ->values(); + } + + /** + * Check whether the given permission matches any of the token abilities. + * + * @param array $abilities + */ + private function matchesAnyAbility(string $permissionId, array $abilities): bool + { + foreach ($abilities as $ability) { + if ($this->matcher->matches($ability, $permissionId)) { + return true; + } + } + + return false; + } +} diff --git a/src/Stores/EloquentBoundaryStore.php b/src/Stores/EloquentBoundaryStore.php index 2eec6b6..1c498be 100644 --- a/src/Stores/EloquentBoundaryStore.php +++ b/src/Stores/EloquentBoundaryStore.php @@ -8,6 +8,7 @@ use DynamikDev\PolicyEngine\Events\BoundaryRemoved; use DynamikDev\PolicyEngine\Events\BoundarySet; use DynamikDev\PolicyEngine\Models\Boundary; +use Illuminate\Database\Connection; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Event; @@ -48,7 +49,9 @@ public function remove(string $scope): void $deleted = Boundary::query()->where('scope', $scope)->delete(); if ($deleted > 0) { - Boundary::query()->getConnection()->afterCommit(function () use ($scope): void { + /** @var Connection $connection */ + $connection = Boundary::query()->getConnection(); + $connection->afterCommit(function () use ($scope): void { Event::dispatch(new BoundaryRemoved($scope)); }); } diff --git a/src/Stores/EloquentPermissionStore.php b/src/Stores/EloquentPermissionStore.php index 34cc126..6c82844 100644 --- a/src/Stores/EloquentPermissionStore.php +++ b/src/Stores/EloquentPermissionStore.php @@ -9,6 +9,7 @@ use DynamikDev\PolicyEngine\Events\PermissionDeleted; use DynamikDev\PolicyEngine\Models\Permission; use DynamikDev\PolicyEngine\Models\RolePermission; +use Illuminate\Database\Connection; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Event; @@ -83,7 +84,9 @@ public function remove(string $id): void $deleted = Permission::query()->where('id', $id)->delete(); if ($deleted > 0) { - Permission::query()->getConnection()->afterCommit(function () use ($id): void { + /** @var Connection $connection */ + $connection = Permission::query()->getConnection(); + $connection->afterCommit(function () use ($id): void { Event::dispatch(new PermissionDeleted($id)); }); } diff --git a/src/Stores/EloquentResourcePolicyStore.php b/src/Stores/EloquentResourcePolicyStore.php new file mode 100644 index 0000000..76fe068 --- /dev/null +++ b/src/Stores/EloquentResourcePolicyStore.php @@ -0,0 +1,110 @@ + + */ + public function forResource(string $type, string|int|null $id): Collection + { + $idString = $id !== null ? (string) $id : null; + + return ResourcePolicy::query() + ->where('resource_type', $type) + ->where(static function ($query) use ($idString): void { + $query->whereNull('resource_id') + ->orWhere('resource_id', $idString); + }) + ->get() + ->map(function (ResourcePolicy $row) use ($type): PolicyStatement { + $conditions = $this->hydrateConditions($row->conditions ?? []); + + $source = $row->resource_id !== null + ? "resource:{$type}:{$row->resource_id}" + : "resource:{$type}"; + + return new PolicyStatement( + effect: $row->getEffectEnum(), + action: $row->action, + principalPattern: $row->principal_pattern, + resourcePattern: null, + conditions: $conditions, + source: $source, + ); + }) + ->values(); + } + + public function attach(string $resourceType, string|int|null $resourceId, PolicyStatement $statement): void + { + $idString = $resourceId !== null ? (string) $resourceId : null; + + ResourcePolicy::query()->create([ + 'resource_type' => $resourceType, + 'resource_id' => $idString, + 'effect' => $statement->effect->name, + 'action' => $statement->action, + 'principal_pattern' => $statement->principalPattern, + 'conditions' => $this->serializeConditions($statement->conditions), + ]); + } + + public function detach(string $resourceType, string|int|null $resourceId, string $action): void + { + $idString = $resourceId !== null ? (string) $resourceId : null; + + ResourcePolicy::query() + ->where('resource_type', $resourceType) + ->where('action', $action) + ->when( + $idString === null, + static fn ($query) => $query->whereNull('resource_id'), + static fn ($query) => $query->where('resource_id', $idString), + ) + ->delete(); + } + + /** + * @param array $raw + * @return array + */ + private function hydrateConditions(array $raw): array + { + return array_map( + static fn (array $item) => new Condition( + type: $item['type'], + parameters: $item['parameters'] ?? [], + ), + array_filter($raw, 'is_array'), + ); + } + + /** + * @param array $conditions + * @return array>|null + */ + private function serializeConditions(array $conditions): ?array + { + if ($conditions === []) { + return null; + } + + return array_map( + static fn (Condition $c) => [ + 'type' => $c->type, + 'parameters' => $c->parameters, + ], + $conditions, + ); + } +} diff --git a/tests/Arch/ArchitectureTest.php b/tests/Arch/ArchitectureTest.php index 24a3ec5..47e7f6e 100644 --- a/tests/Arch/ArchitectureTest.php +++ b/tests/Arch/ArchitectureTest.php @@ -21,6 +21,7 @@ 'DynamikDev\PolicyEngine\Attributes', 'DynamikDev\PolicyEngine\Commands', 'DynamikDev\PolicyEngine\Concerns', + 'DynamikDev\PolicyEngine\Conditions', 'DynamikDev\PolicyEngine\Contracts', 'DynamikDev\PolicyEngine\Documents', 'DynamikDev\PolicyEngine\DTOs', @@ -155,9 +156,12 @@ function getNamespacedClasses(string $srcDir): array ->toImplement('DynamikDev\PolicyEngine\Contracts\Matcher'); }); -it('requires resolvers to implement the ScopeResolver contract', function (): void { - expect('DynamikDev\PolicyEngine\Resolvers') - ->toImplement('DynamikDev\PolicyEngine\Contracts\ScopeResolver'); +it('requires policy resolvers to implement the PolicyResolver contract', function (): void { + expect('DynamikDev\PolicyEngine\Resolvers\IdentityPolicyResolver')->toImplement('DynamikDev\PolicyEngine\Contracts\PolicyResolver'); + expect('DynamikDev\PolicyEngine\Resolvers\BoundaryPolicyResolver')->toImplement('DynamikDev\PolicyEngine\Contracts\PolicyResolver'); + expect('DynamikDev\PolicyEngine\Resolvers\ResourcePolicyResolver')->toImplement('DynamikDev\PolicyEngine\Contracts\PolicyResolver'); + expect('DynamikDev\PolicyEngine\Resolvers\SanctumPolicyResolver')->toImplement('DynamikDev\PolicyEngine\Contracts\PolicyResolver'); + expect('DynamikDev\PolicyEngine\Resolvers\ModelScopeResolver')->toImplement('DynamikDev\PolicyEngine\Contracts\ScopeResolver'); }); it('requires document classes to implement a document contract', function (string $class, string $contract): void { @@ -200,7 +204,7 @@ function getNamespacedClasses(string $srcDir): array // --- Comment style: consecutive // comments must use block syntax --- it('requires multi-line comments to use block syntax', function (): void { - $iterators = new AppendIterator(); + $iterators = new AppendIterator; $iterators->append(new RecursiveIteratorIterator( new RecursiveDirectoryIterator('src', FilesystemIterator::SKIP_DOTS), )); diff --git a/tests/Benchmark/PermissionCheckBenchmarkTest.php b/tests/Benchmark/PermissionCheckBenchmarkTest.php index 3f2eed7..eaf7fa6 100644 --- a/tests/Benchmark/PermissionCheckBenchmarkTest.php +++ b/tests/Benchmark/PermissionCheckBenchmarkTest.php @@ -9,8 +9,10 @@ use DynamikDev\PolicyEngine\Contracts\Evaluator; use DynamikDev\PolicyEngine\Contracts\PermissionStore; use DynamikDev\PolicyEngine\Contracts\RoleStore; -use DynamikDev\PolicyEngine\Evaluators\CachedEvaluator; -use DynamikDev\PolicyEngine\Evaluators\DefaultEvaluator; +use DynamikDev\PolicyEngine\DTOs\Context; +use DynamikDev\PolicyEngine\DTOs\EvaluationRequest; +use DynamikDev\PolicyEngine\DTOs\Principal; +use DynamikDev\PolicyEngine\Enums\Decision; use DynamikDev\PolicyEngine\Models\Assignment; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; @@ -18,7 +20,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Schema; -uses(RefreshDatabase::class); +uses(RefreshDatabase::class)->group('benchmark'); // --- Models --- @@ -80,6 +82,39 @@ class BenchGroup extends Model // --- Helpers --- +/** + * Build an EvaluationRequest from type, id, action, and optional scope string. + */ +function benchRequest(string $type, string|int $id, string $action, ?string $scope = null): EvaluationRequest +{ + return new EvaluationRequest( + principal: new Principal(type: $type, id: $id), + action: $action, + resource: null, + context: new Context(scope: $scope), + ); +} + +/** + * Evaluate a permission check using the given evaluator instance. + */ +function benchCan(Evaluator $evaluator, string $type, string|int $id, string $action, ?string $scope = null): bool +{ + return $evaluator->evaluate(benchRequest($type, $id, $action, $scope))->decision === Decision::Allow; +} + +/** + * Check role membership directly via AssignmentStore (replaces evaluator->hasRole()). + */ +function benchHasRole(AssignmentStore $store, string $type, string|int $id, string $role, ?string $scope = null): bool +{ + if ($scope !== null) { + return $store->forSubjectGlobalAndScope($type, $id, $scope)->contains('role_id', $role); + } + + return $store->forSubjectGlobal($type, $id)->contains('role_id', $role); +} + function seedGroupsPlatform(object $test, int $users, int $groups, int $groupsPerUser): void { // Permissions @@ -193,62 +228,48 @@ function benchReport(string $label, array $stats): void it('benchmarks small scale: 100 users, 20 groups', function (): void { seedGroupsPlatform($this, users: 100, groups: 20, groupsPerUser: 5); - $user = BenchUser::query()->find(1); $scope = 'benchgroup::1'; echo "\n\n SMALL SCALE: 100 users, 20 groups, ~500 assignments\n"; echo ' '.str_repeat('-', 75)."\n"; - // can() — cache miss (first call, evaluates from DB) + // evaluate() — cache miss (first call, evaluates from DB) config()->set('policy-engine.cache.enabled', false); - benchReport('can() uncached', benchmark( - fn () => $this->defaultEvaluator->can(BenchUser::class, 1, "posts.create:{$scope}"), + benchReport('evaluate() uncached', benchmark( + fn () => benchCan($this->defaultEvaluator, BenchUser::class, 1, 'posts.create', $scope), )); - // can() — cache hit + // evaluate() — cache hit config()->set('policy-engine.cache.enabled', true); - $this->cachedEvaluator->can(BenchUser::class, 1, "posts.create:{$scope}"); // warm - benchReport('can() cached', benchmark( - fn () => $this->cachedEvaluator->can(BenchUser::class, 1, "posts.create:{$scope}"), + benchCan($this->cachedEvaluator, BenchUser::class, 1, 'posts.create', $scope); // warm + benchReport('evaluate() cached', benchmark( + fn () => benchCan($this->cachedEvaluator, BenchUser::class, 1, 'posts.create', $scope), )); - // hasRole() — uncached + // hasRole() via AssignmentStore — uncached config()->set('policy-engine.cache.enabled', false); benchReport('hasRole() uncached', benchmark( - fn () => $this->defaultEvaluator->hasRole(BenchUser::class, 1, 'member', $scope), - )); - - // hasRole() — cached - config()->set('policy-engine.cache.enabled', true); - $this->cachedEvaluator->hasRole(BenchUser::class, 1, 'member', $scope); // warm - benchReport('hasRole() cached', benchmark( - fn () => $this->cachedEvaluator->hasRole(BenchUser::class, 1, 'member', $scope), + fn () => benchHasRole($this->assignmentStore, BenchUser::class, 1, 'member', $scope), )); - // effectivePermissions() — uncached + // effectivePermissions() — uncached via BenchUser model config()->set('policy-engine.cache.enabled', false); + $benchUser = BenchUser::query()->find(1); benchReport('effectivePermissions() uncached', benchmark( - fn () => $this->defaultEvaluator->effectivePermissions(BenchUser::class, 1, $scope), + fn () => $benchUser->effectivePermissions($scope), iterations: 50, )); - // effectivePermissions() — cached - config()->set('policy-engine.cache.enabled', true); - $this->cachedEvaluator->effectivePermissions(BenchUser::class, 1, $scope); // warm - benchReport('effectivePermissions() cached', benchmark( - fn () => $this->cachedEvaluator->effectivePermissions(BenchUser::class, 1, $scope), - )); - // Wildcard match config()->set('policy-engine.cache.enabled', false); - benchReport('can() wildcard posts.* uncached', benchmark( - fn () => $this->defaultEvaluator->can(BenchUser::class, 1, "posts.delete.own:{$scope}"), + benchReport('evaluate() wildcard posts.* uncached', benchmark( + fn () => benchCan($this->defaultEvaluator, BenchUser::class, 1, 'posts.delete.own', $scope), )); // Deny rule check $this->assignmentStore->assign(BenchUser::class, 1, 'restricted', $scope); - benchReport('can() with deny rule uncached', benchmark( - fn () => $this->defaultEvaluator->can(BenchUser::class, 1, "posts.create:{$scope}"), + benchReport('evaluate() with deny rule uncached', benchmark( + fn () => benchCan($this->defaultEvaluator, BenchUser::class, 1, 'posts.create', $scope), )); echo "\n"; @@ -266,24 +287,24 @@ function benchReport(string $label, array $stats): void $scope = 'benchgroup::1'; config()->set('policy-engine.cache.enabled', false); - benchReport('can() uncached', benchmark( - fn () => $this->defaultEvaluator->can(BenchUser::class, 1, "posts.create:{$scope}"), + benchReport('evaluate() uncached', benchmark( + fn () => benchCan($this->defaultEvaluator, BenchUser::class, 1, 'posts.create', $scope), )); config()->set('policy-engine.cache.enabled', true); - $this->cachedEvaluator->can(BenchUser::class, 1, "posts.create:{$scope}"); - benchReport('can() cached', benchmark( - fn () => $this->cachedEvaluator->can(BenchUser::class, 1, "posts.create:{$scope}"), + benchCan($this->cachedEvaluator, BenchUser::class, 1, 'posts.create', $scope); + benchReport('evaluate() cached', benchmark( + fn () => benchCan($this->cachedEvaluator, BenchUser::class, 1, 'posts.create', $scope), )); // Simulate write pressure: assign + invalidate + re-evaluate config()->set('policy-engine.cache.enabled', false); $assignCount = 0; - benchReport('assign() + can() cycle', benchmark(function () use (&$assignCount, $scope): void { + benchReport('assign() + evaluate() cycle', benchmark(function () use (&$assignCount, $scope): void { $assignCount++; $userId = ($assignCount % 1000) + 1; $this->assignmentStore->assign(BenchUser::class, $userId, 'member', $scope); - $this->defaultEvaluator->can(BenchUser::class, $userId, "posts.create:{$scope}"); + benchCan($this->defaultEvaluator, BenchUser::class, $userId, 'posts.create', $scope); }, iterations: 50)); echo "\n"; @@ -304,38 +325,34 @@ function benchReport(string $label, array $stats): void // Uncached reads config()->set('policy-engine.cache.enabled', false); - benchReport('can() uncached', benchmark( - fn () => $this->defaultEvaluator->can(BenchUser::class, 1, "posts.create:{$scope}"), + benchReport('evaluate() uncached', benchmark( + fn () => benchCan($this->defaultEvaluator, BenchUser::class, 1, 'posts.create', $scope), )); benchReport('hasRole() uncached', benchmark( - fn () => $this->defaultEvaluator->hasRole(BenchUser::class, 1, 'member', $scope), + fn () => benchHasRole($this->assignmentStore, BenchUser::class, 1, 'member', $scope), )); + $benchUser = BenchUser::query()->find(1); benchReport('effectivePermissions() uncached', benchmark( - fn () => $this->defaultEvaluator->effectivePermissions(BenchUser::class, 1, $scope), + fn () => $benchUser->effectivePermissions($scope), iterations: 50, )); // Cached reads config()->set('policy-engine.cache.enabled', true); - $this->cachedEvaluator->can(BenchUser::class, 1, "posts.create:{$scope}"); - benchReport('can() cached', benchmark( - fn () => $this->cachedEvaluator->can(BenchUser::class, 1, "posts.create:{$scope}"), - )); - - $this->cachedEvaluator->hasRole(BenchUser::class, 1, 'member', $scope); - benchReport('hasRole() cached', benchmark( - fn () => $this->cachedEvaluator->hasRole(BenchUser::class, 1, 'member', $scope), + benchCan($this->cachedEvaluator, BenchUser::class, 1, 'posts.create', $scope); + benchReport('evaluate() cached', benchmark( + fn () => benchCan($this->cachedEvaluator, BenchUser::class, 1, 'posts.create', $scope), )); // Different users (cold cache per user, warm DB) config()->set('policy-engine.cache.enabled', false); $userCycle = 0; - benchReport('can() across 100 different users', benchmark(function () use (&$userCycle, $scope): void { + benchReport('evaluate() across 100 different users', benchmark(function () use (&$userCycle, $scope): void { $userCycle++; $userId = ($userCycle % 100) + 1; - $this->defaultEvaluator->can(BenchUser::class, $userId, "posts.create:{$scope}"); + benchCan($this->defaultEvaluator, BenchUser::class, $userId, 'posts.create', $scope); })); // 10 permission checks for same user (simulates page render) @@ -345,9 +362,9 @@ function benchReport(string $label, array $stats): void 'settings.manage', 'posts.update.any', ]; config()->set('policy-engine.cache.enabled', true); - benchReport('10 can() checks same user (page render)', benchmark(function () use ($permissions, $scope): void { + benchReport('10 evaluate() checks same user (page render)', benchmark(function () use ($permissions, $scope): void { foreach ($permissions as $perm) { - $this->cachedEvaluator->can(BenchUser::class, 1, "{$perm}:{$scope}"); + benchCan($this->cachedEvaluator, BenchUser::class, 1, $perm, $scope); } }, iterations: 50)); diff --git a/tests/Feature/ArtisanCommandsTest.php b/tests/Feature/ArtisanCommandsTest.php index 6e4ecc3..8eafaf7 100644 --- a/tests/Feature/ArtisanCommandsTest.php +++ b/tests/Feature/ArtisanCommandsTest.php @@ -134,7 +134,7 @@ // --- policy-engine:explain --- it('explains an allowed permission check', function (): void { - config()->set('policy-engine.explain', true); + config()->set('policy-engine.trace', true); $roleStore = app(RoleStore::class); $roleStore->save('editor', 'Editor', ['posts.create', 'posts.update']); @@ -150,7 +150,7 @@ }); it('explains a denied permission check', function (): void { - config()->set('policy-engine.explain', true); + config()->set('policy-engine.trace', true); $roleStore = app(RoleStore::class); $roleStore->save('viewer', 'Viewer', ['posts.read']); @@ -162,12 +162,11 @@ ->expectsOutputToContain('user:42') ->expectsOutputToContain('posts.delete') ->expectsOutputToContain('DENY') - ->expectsOutputToContain('viewer') ->assertSuccessful(); }); it('shows error when explain mode is disabled', function (): void { - config()->set('policy-engine.explain', false); + config()->set('policy-engine.trace', false); $this->artisan('policy-engine:explain', ['subject' => 'user::42', 'permission' => 'posts.create']) ->expectsOutputToContain('Explain mode is disabled') @@ -175,7 +174,7 @@ }); it('shows error for invalid subject format in explain', function (): void { - config()->set('policy-engine.explain', true); + config()->set('policy-engine.trace', true); $this->artisan('policy-engine:explain', ['subject' => 'bad-format', 'permission' => 'posts.create']) ->expectsOutputToContain('Invalid subject format') @@ -183,7 +182,7 @@ }); it('explains a scoped permission check', function (): void { - config()->set('policy-engine.explain', true); + config()->set('policy-engine.trace', true); $roleStore = app(RoleStore::class); $roleStore->save('editor', 'Editor', ['posts.create']); @@ -203,7 +202,7 @@ }); it('shows cache hit status in explain output', function (): void { - config()->set('policy-engine.explain', true); + config()->set('policy-engine.trace', true); $roleStore = app(RoleStore::class); $roleStore->save('viewer', 'Viewer', ['posts.read']); @@ -359,9 +358,9 @@ $contents = file_get_contents($path); $decoded = json_decode($contents, true); - expect($decoded)->toHaveKey('version', '1.0'); + expect($decoded)->toHaveKey('version', '2.0'); expect($decoded['permissions'])->toContain('posts.create'); - expect($decoded['roles'][0]['id'])->toBe('editor'); + expect($decoded['roles'])->toHaveKey('editor'); unlink($path); }); diff --git a/tests/Feature/BoundaryPolicyResolverTest.php b/tests/Feature/BoundaryPolicyResolverTest.php new file mode 100644 index 0000000..e077ae9 --- /dev/null +++ b/tests/Feature/BoundaryPolicyResolverTest.php @@ -0,0 +1,212 @@ +permissionStore = app(PermissionStore::class); + $this->boundaryStore = app(BoundaryStore::class); + $this->matcher = app(Matcher::class); +}); + +function makeBoundaryResolver( + BoundaryStore $boundaries, + Matcher $matcher, + PermissionStore $permissionStore, + bool $denyUnboundedScopes = false, + bool $enforceOnGlobal = false, +): BoundaryPolicyResolver { + return new BoundaryPolicyResolver( + boundaries: $boundaries, + matcher: $matcher, + permissionStore: $permissionStore, + denyUnboundedScopes: $denyUnboundedScopes, + enforceOnGlobal: $enforceOnGlobal, + ); +} + +function makeBoundaryRequest(string $action = 'posts.create', ?string $scope = null): EvaluationRequest +{ + return new EvaluationRequest( + principal: new Principal(type: 'App\\Models\\User', id: 1), + action: $action, + context: new Context(scope: $scope), + ); +} + +it('returns empty when context has no scope and enforce_on_global is false', function (): void { + $this->permissionStore->register(['posts.create', 'posts.read']); + $this->boundaryStore->set('org::acme', ['posts.*']); + + $resolver = makeBoundaryResolver( + boundaries: $this->boundaryStore, + matcher: $this->matcher, + permissionStore: $this->permissionStore, + denyUnboundedScopes: false, + enforceOnGlobal: false, + ); + + $statements = $resolver->resolve(makeBoundaryRequest(scope: null)); + + expect($statements)->toBeEmpty(); +}); + +it('returns empty when scope has no boundary and deny_unbounded is false', function (): void { + $this->permissionStore->register(['posts.create', 'posts.read']); + + $resolver = makeBoundaryResolver( + boundaries: $this->boundaryStore, + matcher: $this->matcher, + permissionStore: $this->permissionStore, + denyUnboundedScopes: false, + enforceOnGlobal: false, + ); + + $statements = $resolver->resolve(makeBoundaryRequest(scope: 'org::unknown')); + + expect($statements)->toBeEmpty(); +}); + +it('produces Deny statements for permissions outside the boundary ceiling', function (): void { + $this->permissionStore->register(['posts.create', 'posts.read', 'posts.update', 'posts.delete']); + $this->boundaryStore->set('org::acme', ['posts.read']); + + $resolver = makeBoundaryResolver( + boundaries: $this->boundaryStore, + matcher: $this->matcher, + permissionStore: $this->permissionStore, + denyUnboundedScopes: false, + enforceOnGlobal: false, + ); + + $statements = $resolver->resolve(makeBoundaryRequest(scope: 'org::acme')); + + $deniedActions = $statements->pluck('action')->sort()->values()->all(); + + expect($statements)->not->toBeEmpty() + ->and($deniedActions)->toContain('posts.create') + ->and($deniedActions)->toContain('posts.update') + ->and($deniedActions)->toContain('posts.delete') + ->and($deniedActions)->not->toContain('posts.read'); + + $statements->each(function (PolicyStatement $stmt): void { + expect($stmt->effect)->toBe(Effect::Deny); + }); +}); + +it('produces Deny-all when deny_unbounded_scopes is true and scope has no boundary', function (): void { + $this->permissionStore->register(['posts.create', 'posts.read', 'posts.delete']); + + $resolver = makeBoundaryResolver( + boundaries: $this->boundaryStore, + matcher: $this->matcher, + permissionStore: $this->permissionStore, + denyUnboundedScopes: true, + enforceOnGlobal: false, + ); + + $statements = $resolver->resolve(makeBoundaryRequest(scope: 'org::unknown')); + + expect($statements)->toHaveCount(3); + + $statements->each(function (PolicyStatement $stmt): void { + expect($stmt->effect)->toBe(Effect::Deny); + }); + + $deniedActions = $statements->pluck('action')->sort()->values()->all(); + expect($deniedActions)->toContain('posts.create') + ->and($deniedActions)->toContain('posts.read') + ->and($deniedActions)->toContain('posts.delete'); +}); + +it('all deny statements from a scoped boundary have source starting with boundary:', function (): void { + $this->permissionStore->register(['posts.create', 'posts.read', 'posts.delete']); + $this->boundaryStore->set('org::acme', ['posts.read']); + + $resolver = makeBoundaryResolver( + boundaries: $this->boundaryStore, + matcher: $this->matcher, + permissionStore: $this->permissionStore, + denyUnboundedScopes: false, + enforceOnGlobal: false, + ); + + $statements = $resolver->resolve(makeBoundaryRequest(scope: 'org::acme')); + + expect($statements)->not->toBeEmpty(); + + $statements->each(function (PolicyStatement $stmt): void { + expect($stmt->source)->toStartWith('boundary:'); + }); +}); + +it('all deny statements from an unbounded deny have source starting with boundary:', function (): void { + $this->permissionStore->register(['posts.create', 'posts.read']); + + $resolver = makeBoundaryResolver( + boundaries: $this->boundaryStore, + matcher: $this->matcher, + permissionStore: $this->permissionStore, + denyUnboundedScopes: true, + enforceOnGlobal: false, + ); + + $statements = $resolver->resolve(makeBoundaryRequest(scope: 'org::unknown')); + + expect($statements)->not->toBeEmpty(); + + $statements->each(function (PolicyStatement $stmt): void { + expect($stmt->source)->toStartWith('boundary:'); + }); +}); + +it('ceiling with wildcard pattern allows all matching permissions through', function (): void { + $this->permissionStore->register(['posts.create', 'posts.read', 'posts.delete', 'billing.manage']); + $this->boundaryStore->set('org::acme', ['posts.*']); + + $resolver = makeBoundaryResolver( + boundaries: $this->boundaryStore, + matcher: $this->matcher, + permissionStore: $this->permissionStore, + denyUnboundedScopes: false, + enforceOnGlobal: false, + ); + + $statements = $resolver->resolve(makeBoundaryRequest(scope: 'org::acme')); + + $deniedActions = $statements->pluck('action')->all(); + + expect($deniedActions)->toContain('billing.manage') + ->and($deniedActions)->not->toContain('posts.create') + ->and($deniedActions)->not->toContain('posts.read') + ->and($deniedActions)->not->toContain('posts.delete'); +}); + +it('produces no deny statements when scope has no boundary and deny_unbounded is false with a scope', function (): void { + $this->permissionStore->register(['posts.create']); + + $resolver = makeBoundaryResolver( + boundaries: $this->boundaryStore, + matcher: $this->matcher, + permissionStore: $this->permissionStore, + denyUnboundedScopes: false, + enforceOnGlobal: false, + ); + + // Scope exists in request, but no boundary is defined for it + $statements = $resolver->resolve(makeBoundaryRequest(scope: 'org::nope')); + + expect($statements)->toBeEmpty(); +}); diff --git a/tests/Feature/CacheInvalidationTest.php b/tests/Feature/CacheInvalidationTest.php index 40bc642..3212225 100644 --- a/tests/Feature/CacheInvalidationTest.php +++ b/tests/Feature/CacheInvalidationTest.php @@ -135,31 +135,37 @@ class CacheTestUser extends Model }); it('invalidates cache when a boundary is updated so canDo reflects tighter limits', function (): void { - $this->permissionStore->register(['billing.manage']); - $this->roleStore->save('admin', 'Admin', ['billing.manage']); - $this->user->assign('admin', 'org::acme'); + config()->set('policy-engine.deny_unbounded_scopes', false); - $this->boundaryStore->set('org::acme', ['billing.manage']); + $this->permissionStore->register(['posts.create', 'posts.delete']); + $this->roleStore->save('editor', 'Editor', ['posts.create', 'posts.delete']); + $this->user->assign('editor', 'org::acme'); - expect($this->user->canDo('billing.manage', 'org::acme'))->toBeTrue(); + // Initially no boundary — both permissions allowed. + expect($this->user->canDo('posts.create', 'org::acme'))->toBeTrue() + ->and($this->user->canDo('posts.delete', 'org::acme'))->toBeTrue(); - $this->boundaryStore->set('org::acme', ['posts.*']); + // Set a boundary that only permits posts.create — should invalidate cache. + $this->boundaryStore->set('org::acme', ['posts.create']); - expect($this->user->canDo('billing.manage', 'org::acme'))->toBeFalse(); + expect($this->user->canDo('posts.create', 'org::acme'))->toBeTrue() + ->and($this->user->canDo('posts.delete', 'org::acme'))->toBeFalse(); }); it('invalidates cache when a boundary is removed so canDo reflects the removal', function (): void { - $this->permissionStore->register(['billing.manage']); - $this->roleStore->save('admin', 'Admin', ['billing.manage']); - $this->user->assign('admin', 'org::acme'); + $this->permissionStore->register(['posts.create', 'posts.delete']); + $this->roleStore->save('editor', 'Editor', ['posts.create', 'posts.delete']); + $this->user->assign('editor', 'org::acme'); - $this->boundaryStore->set('org::acme', ['posts.*']); + // Set a tight boundary that denies posts.delete. + $this->boundaryStore->set('org::acme', ['posts.create']); - expect($this->user->canDo('billing.manage', 'org::acme'))->toBeFalse(); + expect($this->user->canDo('posts.delete', 'org::acme'))->toBeFalse(); + // Remove the boundary — should invalidate cache and allow posts.delete again. $this->boundaryStore->remove('org::acme'); - expect($this->user->canDo('billing.manage', 'org::acme'))->toBeTrue(); + expect($this->user->canDo('posts.delete', 'org::acme'))->toBeTrue(); }); // --- Scoped cache invalidation preserves non-policy-engine keys --- diff --git a/tests/Feature/CachedBoundaryLookupTest.php b/tests/Feature/CachedBoundaryLookupTest.php index 3784364..8a17d84 100644 --- a/tests/Feature/CachedBoundaryLookupTest.php +++ b/tests/Feature/CachedBoundaryLookupTest.php @@ -55,135 +55,80 @@ class BoundaryCacheUser extends Model Schema::dropIfExists('boundary_cache_users'); }); -// --- Core: boundary queries are cached across sequential can() checks --- +it('fires at most 2 boundary queries for 100 sequential canDo() checks with enforce_boundaries_on_global', function (): void { + $user = BoundaryCacheUser::query()->create(['name' => 'Alice']); -it('fires at most 2 boundary queries for 100 sequential can() checks with enforce_boundaries_on_global', function (): void { - // Warm any framework-internal queries (e.g., migration state) so they don't pollute the log. - $this->evaluator->can('App\\Models\\User', 1, 'posts.create'); + $user->assign('editor', 'org::acme'); - // Reset the query log and count only boundary-table queries. - DB::enableQueryLog(); - DB::flushQueryLog(); + $queryCount = 0; + DB::listen(function ($query) use (&$queryCount): void { + if (str_contains($query->sql, 'boundaries')) { + $queryCount++; + } + }); for ($i = 0; $i < 100; $i++) { - $this->evaluator->can('App\\Models\\User', 1, 'posts.create'); + $user->canDo('posts.create', 'org::acme'); } - $queries = DB::getQueryLog(); - - /** @var string $boundariesTable */ - $boundariesTable = config('policy-engine.table_prefix', '').'boundaries'; + // The CachingBoundaryStore caches the all() result; boundary queries should be minimal. + expect($queryCount)->toBeLessThanOrEqual(2); +}); - $boundaryQueries = array_filter( - $queries, - fn (array $query): bool => str_contains($query['query'], $boundariesTable), - ); +it('refreshes cached boundaries after a BoundarySet event fires', function (): void { + $user = BoundaryCacheUser::query()->create(['name' => 'Bob']); - /* - * The first can() call above (warm-up) may trigger one boundary query - * that gets cached. Subsequent 100 calls should all serve from cache. - * Allow up to 2 for edge cases (e.g., two code paths: evaluateBoundary + resolveBoundaryMaxPermissions). - */ - expect(count($boundaryQueries))->toBeLessThanOrEqual(2); + $user->assign('editor', 'org::acme'); - DB::disableQueryLog(); -}); + // Initially can do posts.create within org::acme (no tight boundary yet). + expect($user->canDo('posts.create', 'org::acme'))->toBeTrue(); -// --- Boundary cache is invalidated when a boundary changes --- + // Set boundary that restricts to only posts.read — BoundarySet event should flush cache. + $this->boundaryStore->set('org::acme', ['posts.read']); -it('refreshes cached boundaries after a BoundarySet event fires', function (): void { - // User can do posts.create globally because at least one boundary allows posts.*. - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.create'))->toBeTrue(); - - /* - * Now narrow ALL boundaries so that posts.create is no longer allowed anywhere. - * BoundarySet events fire and flush the entire policy-engine cache. - */ - $this->boundaryStore->set('org::acme', ['billing.*']); - $this->boundaryStore->set('org::beta', ['billing.*']); - - // The cached boundary collection should be gone. Next can() re-fetches from DB. - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.create'))->toBeFalse(); + // Cache should be invalidated, posts.create should now be denied. + expect($user->canDo('posts.create', 'org::acme'))->toBeFalse() + ->and($user->canDo('posts.read', 'org::acme'))->toBeTrue(); }); -// --- Boundary cache is invalidated when a boundary is removed --- - it('refreshes cached boundaries after a BoundaryRemoved event fires', function (): void { - // Start with two boundaries that block billing.manage globally. - $this->roleStore->save('admin', 'Admin', ['billing.manage']); - $this->assignmentStore->assign('App\\Models\\User', 2, 'admin'); - $this->permissionStore->register(['billing.manage']); + $user = BoundaryCacheUser::query()->create(['name' => 'Carol']); + + $user->assign('editor', 'org::acme'); + $this->boundaryStore->set('org::acme', ['posts.read']); - // Neither boundary's max_permissions includes billing.manage, so it's denied. - expect($this->evaluator->can('App\\Models\\User', 2, 'billing.manage'))->toBeFalse(); + // posts.create is denied by boundary. + expect($user->canDo('posts.create', 'org::acme'))->toBeFalse(); - // Remove all boundaries — with no boundaries, global enforcement passes. + // Remove boundary — BoundaryRemoved event should flush cache. $this->boundaryStore->remove('org::acme'); - $this->boundaryStore->remove('org::beta'); - expect($this->evaluator->can('App\\Models\\User', 2, 'billing.manage'))->toBeTrue(); + // posts.create should be allowed again. + expect($user->canDo('posts.create', 'org::acme'))->toBeTrue(); }); -// --- Boundary cache bypassed when cache is disabled --- - it('skips boundary cache when cache is disabled', function (): void { config()->set('policy-engine.cache.enabled', false); - // Should still work correctly, just without caching. - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.create'))->toBeTrue(); + $user = BoundaryCacheUser::query()->create(['name' => 'Dave']); - DB::enableQueryLog(); - DB::flushQueryLog(); - - // Each call hits the DB directly (no caching). - for ($i = 0; $i < 5; $i++) { - $this->evaluator->can('App\\Models\\User', 1, 'posts.create'); - } + $user->assign('editor', 'org::acme'); + $this->boundaryStore->set('org::acme', ['posts.read']); - $queries = DB::getQueryLog(); - - /** @var string $boundariesTable */ - $boundariesTable = config('policy-engine.table_prefix', '').'boundaries'; - - $boundaryQueries = array_filter( - $queries, - fn (array $query): bool => str_contains($query['query'], $boundariesTable), - ); - - // Without cache, each can() call should query boundaries. - expect(count($boundaryQueries))->toBeGreaterThanOrEqual(5); - - DB::disableQueryLog(); + // Even with cache disabled, boundary enforcement should work. + expect($user->canDo('posts.create', 'org::acme'))->toBeFalse() + ->and($user->canDo('posts.read', 'org::acme'))->toBeTrue(); }); -// --- effectivePermissions also benefits from cached boundaries --- - -it('caches boundary lookups for effectivePermissions with enforce_boundaries_on_global', function (): void { - // Warm the cache with one call. - $this->evaluator->effectivePermissions('App\\Models\\User', 1); - - DB::enableQueryLog(); - DB::flushQueryLog(); - - for ($i = 0; $i < 50; $i++) { - $this->evaluator->effectivePermissions('App\\Models\\User', 1); - } - - $queries = DB::getQueryLog(); - - /** @var string $boundariesTable */ - $boundariesTable = config('policy-engine.table_prefix', '').'boundaries'; +it('caches boundary lookups for canDo with enforce_boundaries_on_global', function (): void { + $user = BoundaryCacheUser::query()->create(['name' => 'Eve']); - $boundaryQueries = array_filter( - $queries, - fn (array $query): bool => str_contains($query['query'], $boundariesTable), - ); + $user->assign('editor'); - /* - * effectivePermissions is itself cached by CachedEvaluator, but even if it - * falls through, the boundary lookup is cached by CachingBoundaryStore. - */ - expect(count($boundaryQueries))->toBeLessThanOrEqual(2); + /* With enforce_boundaries_on_global=true, combined ceiling is posts.* (org::acme and org::beta seeds). posts.create is within posts.* so allowed; posts.delete is not in any boundary. */ + expect($user->canDo('posts.read'))->toBeTrue() + ->and($user->canDo('posts.create'))->toBeTrue(); - DB::disableQueryLog(); + // posts.delete is not in any ceiling — should be denied. + expect($user->canDo('posts.delete'))->toBeFalse(); }); diff --git a/tests/Feature/CachedEvaluatorTest.php b/tests/Feature/CachedEvaluatorTest.php index 7211e31..f3683ab 100644 --- a/tests/Feature/CachedEvaluatorTest.php +++ b/tests/Feature/CachedEvaluatorTest.php @@ -2,360 +2,125 @@ declare(strict_types=1); -use DynamikDev\PolicyEngine\Contracts\AssignmentStore; -use DynamikDev\PolicyEngine\Contracts\BoundaryStore; use DynamikDev\PolicyEngine\Contracts\Evaluator; -use DynamikDev\PolicyEngine\Contracts\Matcher; -use DynamikDev\PolicyEngine\Contracts\PermissionStore; -use DynamikDev\PolicyEngine\Contracts\RoleStore; -use DynamikDev\PolicyEngine\DTOs\EvaluationTrace; -use DynamikDev\PolicyEngine\Enums\EvaluationResult; +use DynamikDev\PolicyEngine\DTOs\Context; +use DynamikDev\PolicyEngine\DTOs\EvaluationRequest; +use DynamikDev\PolicyEngine\DTOs\EvaluationResult; +use DynamikDev\PolicyEngine\DTOs\Principal; +use DynamikDev\PolicyEngine\Enums\Decision; use DynamikDev\PolicyEngine\Evaluators\CachedEvaluator; -use DynamikDev\PolicyEngine\Evaluators\DefaultEvaluator; -use DynamikDev\PolicyEngine\Events\AssignmentCreated; -use DynamikDev\PolicyEngine\Events\AssignmentRevoked; -use DynamikDev\PolicyEngine\Events\RoleDeleted; -use DynamikDev\PolicyEngine\Events\RoleUpdated; -use DynamikDev\PolicyEngine\Listeners\InvalidatePermissionCache; -use DynamikDev\PolicyEngine\Models\Assignment; -use DynamikDev\PolicyEngine\Models\Role; use Illuminate\Cache\CacheManager; -use Illuminate\Foundation\Testing\RefreshDatabase; -uses(RefreshDatabase::class); - -beforeEach(function (): void { - $this->permissionStore = app(PermissionStore::class); - $this->roleStore = app(RoleStore::class); - $this->assignmentStore = app(AssignmentStore::class); - $this->boundaryStore = app(BoundaryStore::class); - - $this->inner = new DefaultEvaluator( - assignments: $this->assignmentStore, - roles: $this->roleStore, - boundaries: $this->boundaryStore, - matcher: app(Matcher::class), +function makeTestRequest(string $action, ?string $scope = null): EvaluationRequest +{ + return new EvaluationRequest( + principal: new Principal(type: 'user', id: 1), + action: $action, + resource: null, + context: new Context(scope: $scope), ); +} - $this->cacheManager = app(CacheManager::class); +function makeAllowResult(): EvaluationResult +{ + return new EvaluationResult( + decision: Decision::Allow, + decidedBy: 'role:editor', + ); +} - $this->evaluator = new CachedEvaluator( - inner: $this->inner, - cache: $this->cacheManager, +function makeDenyResult(): EvaluationResult +{ + return new EvaluationResult( + decision: Decision::Deny, + decidedBy: 'default-deny', ); +} +beforeEach(function (): void { config()->set('policy-engine.cache.enabled', true); config()->set('policy-engine.cache.store', 'array'); config()->set('policy-engine.cache.ttl', 300); }); -// --- Cache miss: delegates to inner evaluator and caches --- - -it('resolves permissions on cache miss and caches the result', function (): void { - $this->permissionStore->register(['posts.create', 'posts.read']); - $this->roleStore->save('editor', 'Editor', ['posts.create', 'posts.read']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); - - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.create'))->toBeTrue(); - - // Verify the result was cached per permission (tagged store for array driver). - $cacheKey = CachedEvaluator::cacheKey('App\\Models\\User', 1, 'posts.create'); - $cached = $this->cacheManager->store('array')->tags(['policy-engine', 'pe:App\\Models\\User:1'])->get($cacheKey); - - expect($cached)->toBe(['v' => true]); -}); - -// --- Cache hit: serves from cache without inner evaluation --- +// --- Delegates on miss --- -it('serves from cache on subsequent calls', function (): void { - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); +it('calls inner evaluator once on cache miss and returns result', function (): void { + $inner = Mockery::mock(Evaluator::class); + $request = makeTestRequest('posts.create'); + $expected = makeAllowResult(); - // First call populates cache. - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.create'))->toBeTrue(); + $inner->expects('evaluate') + ->once() + ->with(Mockery::type(EvaluationRequest::class)) + ->andReturn($expected); - // Delete assignment directly in DB to avoid triggering cache invalidation events. - Assignment::query() - ->where('subject_type', 'App\\Models\\User') - ->where('subject_id', 1) - ->delete(); + $evaluator = new CachedEvaluator(inner: $inner, cache: app(CacheManager::class)); + $result = $evaluator->evaluate($request); - // Second call should still return true from cache despite DB change. - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.create'))->toBeTrue(); + expect($result->decision)->toBe(Decision::Allow) + ->and($result->decidedBy)->toBe('role:editor'); }); -// --- Denied permission is served from cache, not re-evaluated --- - -it('caches denied permissions and returns false from cache without re-evaluating', function (): void { - $this->permissionStore->register(['posts.delete']); - $this->roleStore->save('viewer', 'Viewer', ['posts.read']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'viewer'); - - // Build a spy that counts calls to the inner evaluator. - $callCount = 0; - $spy = new class($this->inner, $callCount) implements Evaluator - { - public function __construct( - private readonly Evaluator $delegate, - private int &$callCount, - ) {} - - public function can(string $subjectType, string|int $subjectId, string $permission): bool - { - $this->callCount++; - - return $this->delegate->can($subjectType, $subjectId, $permission); - } +// --- Returns cached on hit --- - public function explain(string $subjectType, string|int $subjectId, string $permission): EvaluationTrace - { - return $this->delegate->explain($subjectType, $subjectId, $permission); - } +it('returns cached result on second call without calling inner again', function (): void { + $inner = Mockery::mock(Evaluator::class); + $request = makeTestRequest('posts.read'); - public function effectivePermissions(string $subjectType, string|int $subjectId, ?string $scope = null): array - { - return $this->delegate->effectivePermissions($subjectType, $subjectId, $scope); - } + $inner->expects('evaluate') + ->once() + ->andReturn(makeAllowResult()); - public function hasRole(string $subjectType, string|int $subjectId, string $role, ?string $scope = null): bool - { - return $this->delegate->hasRole($subjectType, $subjectId, $role, $scope); - } - }; + $evaluator = new CachedEvaluator(inner: $inner, cache: app(CacheManager::class)); - $evaluator = new CachedEvaluator(inner: $spy, cache: $this->cacheManager); + $first = $evaluator->evaluate($request); + $second = $evaluator->evaluate($request); - // First call: cache miss, delegates to inner evaluator. - expect($evaluator->can('App\\Models\\User', 1, 'posts.delete'))->toBeFalse(); - expect($callCount)->toBe(1); - - // Second call: cache hit, must NOT delegate to inner evaluator. - expect($evaluator->can('App\\Models\\User', 1, 'posts.delete'))->toBeFalse(); - expect($callCount)->toBe(1); + expect($first->decision)->toBe(Decision::Allow) + ->and($second->decision)->toBe(Decision::Allow); }); -// --- Cache invalidation on assignment change --- - -it('invalidates cache when an assignment is created', function (): void { - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('editor', 'Editor', ['posts.create']); - - // Pre-populate cache with a deny result in the tagged store (no assignments yet). - $cacheKey = CachedEvaluator::cacheKey('App\\Models\\User', 1, 'posts.create'); - $this->cacheManager->store('array')->tags(['policy-engine', 'pe:App\\Models\\User:1'])->put($cacheKey, ['v' => false], 3600); - - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.create'))->toBeFalse(); - - // Create assignment and fire listener manually. - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); - $assignment = Assignment::query()->first(); - - $listener = new InvalidatePermissionCache($this->cacheManager); - $listener->handle(new AssignmentCreated($assignment)); - - // Cache should be cleared — next call re-evaluates. - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.create'))->toBeTrue(); -}); - -it('invalidates cache when an assignment is revoked', function (): void { - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); - - // Populate cache. - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.create'))->toBeTrue(); - - // Revoke and fire listener. - $assignment = Assignment::query()->first(); - $this->assignmentStore->revoke('App\\Models\\User', 1, 'editor'); - - $listener = new InvalidatePermissionCache($this->cacheManager); - $listener->handle(new AssignmentRevoked($assignment)); - - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.create'))->toBeFalse(); -}); - -// --- Cache invalidation on role change --- - -it('invalidates cache when a role is updated', function (): void { - $this->permissionStore->register(['posts.create', 'posts.delete']); - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); - - // Populate cache — posts.delete should be denied. - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.delete'))->toBeFalse(); - - // Update role to include posts.delete. - $this->roleStore->save('editor', 'Editor', ['posts.create', 'posts.delete']); - $role = Role::query()->find('editor'); - - $listener = new InvalidatePermissionCache($this->cacheManager); - $listener->handle(new RoleUpdated($role, ['permissions' => ['posts.create', 'posts.delete']])); - - // Cache cleared — now should allow. - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.delete'))->toBeTrue(); -}); - -it('invalidates cache when a role is deleted', function (): void { - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); - - // Populate cache. - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.create'))->toBeTrue(); - - /* - * Revoke assignment first, then delete the role. - * (SQLite does not enforce FK cascades by default.) - */ - $this->assignmentStore->revoke('App\\Models\\User', 1, 'editor'); - $role = Role::query()->find('editor'); - $this->roleStore->remove('editor'); +// --- Bypasses cache when disabled --- - $listener = new InvalidatePermissionCache($this->cacheManager); - $listener->handle(new RoleDeleted($role)); - - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.create'))->toBeFalse(); -}); - -// --- Cache disabled config --- - -it('bypasses cache when cache is disabled', function (): void { +it('bypasses cache when cache is disabled and calls inner twice', function (): void { config()->set('policy-engine.cache.enabled', false); - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); - - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.create'))->toBeTrue(); - - // Verify nothing was cached. - $cacheKey = CachedEvaluator::cacheKey('App\\Models\\User', 1, 'posts.create'); - $cached = $this->cacheManager->store('array')->get($cacheKey); - - expect($cached)->toBeNull(); -}); - -// --- Explain and effectivePermissions delegate to inner --- - -it('delegates explain() to inner evaluator', function (): void { - config()->set('policy-engine.explain', true); - - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); - - $trace = $this->evaluator->explain('App\\Models\\User', 1, 'posts.create'); - - expect($trace->result)->toBe(EvaluationResult::Allow) - ->and($trace->cacheHit)->toBeFalse(); -}); - -it('delegates effectivePermissions() to inner evaluator', function (): void { - $this->permissionStore->register(['posts.create', 'posts.read']); - $this->roleStore->save('editor', 'Editor', ['posts.create', 'posts.read']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); - - $permissions = $this->evaluator->effectivePermissions('App\\Models\\User', 1); - - expect($permissions)->toContain('posts.create', 'posts.read'); -}); - -// --- Scoped cache key --- + $inner = Mockery::mock(Evaluator::class); + $request = makeTestRequest('posts.delete'); -// --- hasRole caching --- + $inner->expects('evaluate') + ->twice() + ->andReturn(makeDenyResult()); -it('caches hasRole results', function (): void { - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); + $evaluator = new CachedEvaluator(inner: $inner, cache: app(CacheManager::class)); - expect($this->evaluator->hasRole('App\\Models\\User', 1, 'editor'))->toBeTrue(); - - // Delete assignment directly to avoid cache invalidation events. - Assignment::query()->delete(); - - // Should still return true from cache. - expect($this->evaluator->hasRole('App\\Models\\User', 1, 'editor'))->toBeTrue(); -}); - -it('invalidates hasRole cache on assignment revoke', function (): void { - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); - - expect($this->evaluator->hasRole('App\\Models\\User', 1, 'editor'))->toBeTrue(); - - // Revoke via store (fires invalidation events). - $assignment = Assignment::query()->first(); - $this->assignmentStore->revoke('App\\Models\\User', 1, 'editor'); - - $listener = new InvalidatePermissionCache($this->cacheManager); - $listener->handle(new AssignmentRevoked($assignment)); - - expect($this->evaluator->hasRole('App\\Models\\User', 1, 'editor'))->toBeFalse(); + $evaluator->evaluate($request); + $evaluator->evaluate($request); }); -// --- effectivePermissions caching --- - -it('caches effectivePermissions results', function (): void { - $this->permissionStore->register(['posts.create', 'posts.read']); - $this->roleStore->save('editor', 'Editor', ['posts.create', 'posts.read']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); - - $first = $this->evaluator->effectivePermissions('App\\Models\\User', 1); - expect($first)->toEqualCanonicalizing(['posts.create', 'posts.read']); +// --- Different requests cached separately --- - // Delete assignment directly to avoid cache invalidation. - Assignment::query()->delete(); - - // Should still return cached result. - $second = $this->evaluator->effectivePermissions('App\\Models\\User', 1); - expect($second)->toEqualCanonicalizing(['posts.create', 'posts.read']); -}); - -// --- Cache key isolation --- - -it('does not collide cache keys between can, hasRole, and effectivePermissions', function (): void { - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); - - // Populate all three cache types for the same subject. - $canResult = $this->evaluator->can('App\\Models\\User', 1, 'posts.create'); - $roleResult = $this->evaluator->hasRole('App\\Models\\User', 1, 'editor'); - $effectiveResult = $this->evaluator->effectivePermissions('App\\Models\\User', 1); - - expect($canResult)->toBeTrue() - ->and($roleResult)->toBeTrue() - ->and($effectiveResult)->toContain('posts.create'); - - // Verify keys are distinct. - $canKey = CachedEvaluator::key('can', 'App\\Models\\User', 1, 'posts.create'); - $roleKey = CachedEvaluator::key('role', 'App\\Models\\User', 1, 'editor'); - $effectiveKey = CachedEvaluator::key('effective', 'App\\Models\\User', 1); - - expect($canKey)->not->toBe($roleKey) - ->and($canKey)->not->toBe($effectiveKey) - ->and($roleKey)->not->toBe($effectiveKey); -}); +it('caches different actions under separate keys and calls inner once each', function (): void { + $inner = Mockery::mock(Evaluator::class); + $createRequest = makeTestRequest('posts.create'); + $deleteRequest = makeTestRequest('posts.delete'); -// --- Scoped cache keys --- + $inner->expects('evaluate') + ->twice() + ->andReturnUsing(function (EvaluationRequest $req): EvaluationResult { + return $req->action === 'posts.create' ? makeAllowResult() : makeDenyResult(); + }); -it('uses separate cache keys for different scopes', function (): void { - $this->permissionStore->register(['posts.create', 'posts.delete']); - $this->roleStore->save('team-editor', 'Team Editor', ['posts.create']); - $this->roleStore->save('org-admin', 'Org Admin', ['posts.create', 'posts.delete']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'team-editor', 'team::5'); - $this->assignmentStore->assign('App\\Models\\User', 1, 'org-admin', 'org::acme'); + $evaluator = new CachedEvaluator(inner: $inner, cache: app(CacheManager::class)); - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.create:team::5'))->toBeTrue() - ->and($this->evaluator->can('App\\Models\\User', 1, 'posts.delete:team::5'))->toBeFalse() - ->and($this->evaluator->can('App\\Models\\User', 1, 'posts.delete:org::acme'))->toBeTrue(); + $createResult = $evaluator->evaluate($createRequest); + $deleteResult = $evaluator->evaluate($deleteRequest); - // Verify separate cache keys exist for different permission+scope combos (tagged store). - $teamCreateKey = CachedEvaluator::cacheKey('App\\Models\\User', 1, 'posts.create:team::5'); - $orgDeleteKey = CachedEvaluator::cacheKey('App\\Models\\User', 1, 'posts.delete:org::acme'); + // Call again to verify cache hit — no additional inner calls. + $evaluator->evaluate($createRequest); + $evaluator->evaluate($deleteRequest); - expect($this->cacheManager->store('array')->tags(['policy-engine', 'pe:App\\Models\\User:1'])->get($teamCreateKey))->toBe(['v' => true]) - ->and($this->cacheManager->store('array')->tags(['policy-engine', 'pe:App\\Models\\User:1'])->get($orgDeleteKey))->toBe(['v' => true]); + expect($createResult->decision)->toBe(Decision::Allow) + ->and($deleteResult->decision)->toBe(Decision::Deny); }); diff --git a/tests/Feature/ConditionSystemTest.php b/tests/Feature/ConditionSystemTest.php new file mode 100644 index 0000000..1720c06 --- /dev/null +++ b/tests/Feature/ConditionSystemTest.php @@ -0,0 +1,502 @@ +statements); + } + }; +} + +function makeRegistry(): DefaultConditionRegistry +{ + $registry = new DefaultConditionRegistry; + $registry->register('attribute_equals', AttributeEqualsEvaluator::class); + $registry->register('attribute_in', AttributeInEvaluator::class); + $registry->register('environment_equals', EnvironmentEqualsEvaluator::class); + $registry->register('ip_range', IpRangeEvaluator::class); + $registry->register('time_between', TimeBetweenEvaluator::class); + + return $registry; +} + +/* Registry tests */ + +it('resolves a registered evaluator by type', function (): void { + $registry = new DefaultConditionRegistry; + $registry->register('attribute_equals', AttributeEqualsEvaluator::class); + + $evaluator = $registry->evaluatorFor('attribute_equals'); + + expect($evaluator)->toBeInstanceOf(AttributeEqualsEvaluator::class); +}); + +it('throws InvalidArgumentException for an unknown condition type', function (): void { + $registry = new DefaultConditionRegistry; + + expect(fn () => $registry->evaluatorFor('nonexistent'))->toThrow(InvalidArgumentException::class); +}); + +it('registers the built-in types via the service container binding', function (): void { + $registry = app(ConditionRegistry::class); + + expect($registry->evaluatorFor('attribute_equals'))->toBeInstanceOf(AttributeEqualsEvaluator::class) + ->and($registry->evaluatorFor('attribute_in'))->toBeInstanceOf(AttributeInEvaluator::class) + ->and($registry->evaluatorFor('environment_equals'))->toBeInstanceOf(EnvironmentEqualsEvaluator::class) + ->and($registry->evaluatorFor('ip_range'))->toBeInstanceOf(IpRangeEvaluator::class) + ->and($registry->evaluatorFor('time_between'))->toBeInstanceOf(TimeBetweenEvaluator::class); +}); + +/* AttributeEqualsEvaluator tests */ + +it('AttributeEqualsEvaluator passes when subject and resource attribute values match', function (): void { + $evaluator = new AttributeEqualsEvaluator; + $condition = new Condition('attribute_equals', ['subject_key' => 'dept', 'resource_key' => 'dept']); + + $request = conditionRequest( + resource: new Resource(type: 'doc', id: 1, attributes: ['dept' => 'engineering']), + principalAttributes: ['dept' => 'engineering'], + ); + + expect($evaluator->passes($condition, $request))->toBeTrue(); +}); + +it('AttributeEqualsEvaluator fails when subject and resource attribute values differ', function (): void { + $evaluator = new AttributeEqualsEvaluator; + $condition = new Condition('attribute_equals', ['subject_key' => 'dept', 'resource_key' => 'dept']); + + $request = conditionRequest( + resource: new Resource(type: 'doc', id: 1, attributes: ['dept' => 'marketing']), + principalAttributes: ['dept' => 'engineering'], + ); + + expect($evaluator->passes($condition, $request))->toBeFalse(); +}); + +it('AttributeEqualsEvaluator fails when resource is null', function (): void { + $evaluator = new AttributeEqualsEvaluator; + $condition = new Condition('attribute_equals', ['subject_key' => 'dept', 'resource_key' => 'dept']); + + $request = conditionRequest(principalAttributes: ['dept' => 'engineering']); + + expect($evaluator->passes($condition, $request))->toBeFalse(); +}); + +it('AttributeEqualsEvaluator fails when subject key is missing from principal attributes', function (): void { + $evaluator = new AttributeEqualsEvaluator; + $condition = new Condition('attribute_equals', ['subject_key' => 'dept', 'resource_key' => 'dept']); + + $request = conditionRequest( + resource: new Resource(type: 'doc', id: 1, attributes: ['dept' => 'engineering']), + principalAttributes: [], + ); + + expect($evaluator->passes($condition, $request))->toBeFalse(); +}); + +it('AttributeEqualsEvaluator fails when resource key is missing from resource attributes', function (): void { + $evaluator = new AttributeEqualsEvaluator; + $condition = new Condition('attribute_equals', ['subject_key' => 'dept', 'resource_key' => 'dept']); + + $request = conditionRequest( + resource: new Resource(type: 'doc', id: 1, attributes: []), + principalAttributes: ['dept' => 'engineering'], + ); + + expect($evaluator->passes($condition, $request))->toBeFalse(); +}); + +/* AttributeInEvaluator tests */ + +it('AttributeInEvaluator passes when principal attribute value is in the allowed set', function (): void { + $evaluator = new AttributeInEvaluator; + $condition = new Condition('attribute_in', [ + 'source' => 'principal', + 'key' => 'role', + 'values' => ['admin', 'editor'], + ]); + + $request = conditionRequest(principalAttributes: ['role' => 'editor']); + + expect($evaluator->passes($condition, $request))->toBeTrue(); +}); + +it('AttributeInEvaluator fails when principal attribute value is not in the allowed set', function (): void { + $evaluator = new AttributeInEvaluator; + $condition = new Condition('attribute_in', [ + 'source' => 'principal', + 'key' => 'role', + 'values' => ['admin', 'editor'], + ]); + + $request = conditionRequest(principalAttributes: ['role' => 'viewer']); + + expect($evaluator->passes($condition, $request))->toBeFalse(); +}); + +it('AttributeInEvaluator passes when resource attribute value is in the allowed set', function (): void { + $evaluator = new AttributeInEvaluator; + $condition = new Condition('attribute_in', [ + 'source' => 'resource', + 'key' => 'status', + 'values' => ['published', 'draft'], + ]); + + $request = conditionRequest( + resource: new Resource(type: 'post', id: 1, attributes: ['status' => 'draft']), + ); + + expect($evaluator->passes($condition, $request))->toBeTrue(); +}); + +it('AttributeInEvaluator passes when environment value is in the allowed set', function (): void { + $evaluator = new AttributeInEvaluator; + $condition = new Condition('attribute_in', [ + 'source' => 'environment', + 'key' => 'region', + 'values' => ['us-east-1', 'us-west-2'], + ]); + + $request = conditionRequest(environment: ['region' => 'us-east-1']); + + expect($evaluator->passes($condition, $request))->toBeTrue(); +}); + +it('AttributeInEvaluator uses strict type comparison', function (): void { + $evaluator = new AttributeInEvaluator; + $condition = new Condition('attribute_in', [ + 'source' => 'principal', + 'key' => 'level', + 'values' => [1, 2, 3], + ]); + + // String "1" should NOT match integer 1 in strict mode + $request = conditionRequest(principalAttributes: ['level' => '1']); + + expect($evaluator->passes($condition, $request))->toBeFalse(); +}); + +/* EnvironmentEqualsEvaluator tests */ + +it('EnvironmentEqualsEvaluator passes when env key matches expected value', function (): void { + $evaluator = new EnvironmentEqualsEvaluator; + $condition = new Condition('environment_equals', ['key' => 'env', 'value' => 'production']); + + $request = conditionRequest(environment: ['env' => 'production']); + + expect($evaluator->passes($condition, $request))->toBeTrue(); +}); + +it('EnvironmentEqualsEvaluator fails when env key value differs from expected', function (): void { + $evaluator = new EnvironmentEqualsEvaluator; + $condition = new Condition('environment_equals', ['key' => 'env', 'value' => 'production']); + + $request = conditionRequest(environment: ['env' => 'staging']); + + expect($evaluator->passes($condition, $request))->toBeFalse(); +}); + +it('EnvironmentEqualsEvaluator fails when env key is absent', function (): void { + $evaluator = new EnvironmentEqualsEvaluator; + $condition = new Condition('environment_equals', ['key' => 'env', 'value' => 'production']); + + $request = conditionRequest(environment: []); + + expect($evaluator->passes($condition, $request))->toBeFalse(); +}); + +/* IpRangeEvaluator tests */ + +it('IpRangeEvaluator passes when IP is within a CIDR range', function (): void { + $evaluator = new IpRangeEvaluator; + $condition = new Condition('ip_range', ['ranges' => ['10.0.0.0/8']]); + + $request = conditionRequest(environment: ['ip' => '10.5.6.7']); + + expect($evaluator->passes($condition, $request))->toBeTrue(); +}); + +it('IpRangeEvaluator fails when IP is outside all CIDR ranges', function (): void { + $evaluator = new IpRangeEvaluator; + $condition = new Condition('ip_range', ['ranges' => ['10.0.0.0/8']]); + + $request = conditionRequest(environment: ['ip' => '192.168.1.1']); + + expect($evaluator->passes($condition, $request))->toBeFalse(); +}); + +it('IpRangeEvaluator passes when IP matches an exact IP in ranges', function (): void { + $evaluator = new IpRangeEvaluator; + $condition = new Condition('ip_range', ['ranges' => ['192.168.1.100']]); + + $request = conditionRequest(environment: ['ip' => '192.168.1.100']); + + expect($evaluator->passes($condition, $request))->toBeTrue(); +}); + +it('IpRangeEvaluator fails when no IP is in the environment', function (): void { + $evaluator = new IpRangeEvaluator; + $condition = new Condition('ip_range', ['ranges' => ['10.0.0.0/8']]); + + $request = conditionRequest(environment: []); + + expect($evaluator->passes($condition, $request))->toBeFalse(); +}); + +it('IpRangeEvaluator passes when IP matches any of multiple ranges', function (): void { + $evaluator = new IpRangeEvaluator; + $condition = new Condition('ip_range', ['ranges' => ['10.0.0.0/8', '172.16.0.0/12']]); + + $request = conditionRequest(environment: ['ip' => '172.20.5.1']); + + expect($evaluator->passes($condition, $request))->toBeTrue(); +}); + +it('IpRangeEvaluator handles a /32 single-host CIDR correctly', function (): void { + $evaluator = new IpRangeEvaluator; + $condition = new Condition('ip_range', ['ranges' => ['10.0.0.1/32']]); + + $inRange = conditionRequest(environment: ['ip' => '10.0.0.1']); + $outOfRange = conditionRequest(environment: ['ip' => '10.0.0.2']); + + expect($evaluator->passes($condition, $inRange))->toBeTrue() + ->and($evaluator->passes($condition, $outOfRange))->toBeFalse(); +}); + +/* TimeBetweenEvaluator tests */ + +it('TimeBetweenEvaluator passes when current time falls within the window', function (): void { + $evaluator = new TimeBetweenEvaluator; + $now = Carbon::now('UTC'); + + // Build a window that starts 1 hour ago and ends 1 hour from now + $start = $now->copy()->subHour()->format('H:i'); + $end = $now->copy()->addHour()->format('H:i'); + + $condition = new Condition('time_between', [ + 'start' => $start, + 'end' => $end, + 'timezone' => 'UTC', + ]); + + $request = conditionRequest(); + + expect($evaluator->passes($condition, $request))->toBeTrue(); +}); + +it('TimeBetweenEvaluator fails when current time is outside the window', function (): void { + $evaluator = new TimeBetweenEvaluator; + $now = Carbon::now('UTC'); + + // Build a window 2 hours ago that ended 1 hour ago + $start = $now->copy()->subHours(2)->format('H:i'); + $end = $now->copy()->subHour()->format('H:i'); + + // Only do this test when start < end (no midnight wrap) + if ($start >= $end) { + expect(true)->toBeTrue(); // skip gracefully + + return; + } + + $condition = new Condition('time_between', [ + 'start' => $start, + 'end' => $end, + 'timezone' => 'UTC', + ]); + + $request = conditionRequest(); + + expect($evaluator->passes($condition, $request))->toBeFalse(); +}); + +it('TimeBetweenEvaluator returns false when start parameter is missing', function (): void { + $evaluator = new TimeBetweenEvaluator; + $condition = new Condition('time_between', ['end' => '18:00', 'timezone' => 'UTC']); + + expect($evaluator->passes($condition, conditionRequest()))->toBeFalse(); +}); + +it('TimeBetweenEvaluator returns false when end parameter is missing', function (): void { + $evaluator = new TimeBetweenEvaluator; + $condition = new Condition('time_between', ['start' => '09:00', 'timezone' => 'UTC']); + + expect($evaluator->passes($condition, conditionRequest()))->toBeFalse(); +}); + +/* Integration: conditions wired into DefaultEvaluator */ + +it('DefaultEvaluator skips a statement when its condition fails', function (): void { + $registry = makeRegistry(); + + $statement = new PolicyStatement( + effect: Effect::Allow, + action: 'posts.read', + conditions: [ + new Condition('environment_equals', ['key' => 'env', 'value' => 'production']), + ], + source: 'policy:env-gate', + ); + + $evaluator = new DefaultEvaluator( + resolvers: [conditionResolver([$statement])], + matcher: app(Matcher::class), + conditionRegistry: $registry, + ); + + // Staging environment — condition should fail, statement excluded, default-deny applies + $request = conditionRequest(environment: ['env' => 'staging']); + + $result = $evaluator->evaluate($request); + + expect($result->decision)->toBe(Decision::Deny) + ->and($result->decidedBy)->toBe('default-deny'); +}); + +it('DefaultEvaluator applies a statement when its condition passes', function (): void { + $registry = makeRegistry(); + + $statement = new PolicyStatement( + effect: Effect::Allow, + action: 'posts.read', + conditions: [ + new Condition('environment_equals', ['key' => 'env', 'value' => 'production']), + ], + source: 'policy:env-gate', + ); + + $evaluator = new DefaultEvaluator( + resolvers: [conditionResolver([$statement])], + matcher: app(Matcher::class), + conditionRegistry: $registry, + ); + + // Production environment — condition passes, Allow granted + $request = conditionRequest(environment: ['env' => 'production']); + + $result = $evaluator->evaluate($request); + + expect($result->decision)->toBe(Decision::Allow) + ->and($result->decidedBy)->toBe('policy:env-gate'); +}); + +it('DefaultEvaluator applies a statement with no conditions regardless of registry', function (): void { + $registry = makeRegistry(); + + $statement = new PolicyStatement( + effect: Effect::Allow, + action: 'posts.read', + conditions: [], + source: 'policy:unconditional', + ); + + $evaluator = new DefaultEvaluator( + resolvers: [conditionResolver([$statement])], + matcher: app(Matcher::class), + conditionRegistry: $registry, + ); + + $result = $evaluator->evaluate(conditionRequest()); + + expect($result->decision)->toBe(Decision::Allow) + ->and($result->decidedBy)->toBe('policy:unconditional'); +}); + +it('DefaultEvaluator applies statements when conditionRegistry is null and conditions are present', function (): void { + // Without a registry wired in, conditions are bypassed (treated as passing) + $statement = new PolicyStatement( + effect: Effect::Allow, + action: 'posts.read', + conditions: [ + new Condition('environment_equals', ['key' => 'env', 'value' => 'production']), + ], + source: 'policy:no-registry', + ); + + $evaluator = new DefaultEvaluator( + resolvers: [conditionResolver([$statement])], + matcher: app(Matcher::class), + conditionRegistry: null, + ); + + $result = $evaluator->evaluate(conditionRequest(environment: ['env' => 'staging'])); + + expect($result->decision)->toBe(Decision::Allow) + ->and($result->decidedBy)->toBe('policy:no-registry'); +}); + +it('DefaultEvaluator evaluates all conditions and fails fast on the first failure', function (): void { + $registry = makeRegistry(); + + $statement = new PolicyStatement( + effect: Effect::Allow, + action: 'posts.read', + conditions: [ + new Condition('environment_equals', ['key' => 'env', 'value' => 'production']), + new Condition('attribute_in', ['source' => 'principal', 'key' => 'role', 'values' => ['admin']]), + ], + source: 'policy:multi-condition', + ); + + $evaluator = new DefaultEvaluator( + resolvers: [conditionResolver([$statement])], + matcher: app(Matcher::class), + conditionRegistry: $registry, + ); + + // env matches but role does not — overall should deny + $request = conditionRequest( + principalAttributes: ['role' => 'editor'], + environment: ['env' => 'production'], + ); + + $result = $evaluator->evaluate($request); + + expect($result->decision)->toBe(Decision::Deny) + ->and($result->decidedBy)->toBe('default-deny'); +}); diff --git a/tests/Feature/DefaultDocumentExporterTest.php b/tests/Feature/DefaultDocumentExporterTest.php index 014a18d..f9c7d9e 100644 --- a/tests/Feature/DefaultDocumentExporterTest.php +++ b/tests/Feature/DefaultDocumentExporterTest.php @@ -8,8 +8,11 @@ use DynamikDev\PolicyEngine\Contracts\DocumentImporter; use DynamikDev\PolicyEngine\Contracts\DocumentParser; use DynamikDev\PolicyEngine\Contracts\PermissionStore; +use DynamikDev\PolicyEngine\Contracts\ResourcePolicyStore; use DynamikDev\PolicyEngine\Contracts\RoleStore; use DynamikDev\PolicyEngine\DTOs\ImportOptions; +use DynamikDev\PolicyEngine\DTOs\PolicyStatement; +use DynamikDev\PolicyEngine\Enums\Effect; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); @@ -19,6 +22,7 @@ $this->roleStore = app(RoleStore::class); $this->assignmentStore = app(AssignmentStore::class); $this->boundaryStore = app(BoundaryStore::class); + $this->resourcePolicyStore = app(ResourcePolicyStore::class); $this->exporter = app(DocumentExporter::class); }); @@ -42,22 +46,20 @@ function seedFullState($context): void expect($document->permissions)->toBe(['posts.create', 'posts.delete', 'posts.update']); }); -it('exports all roles with their permissions', function (): void { +it('exports all roles in v2 keyed format with their permissions', function (): void { seedFullState($this); $document = $this->exporter->export(); expect($document->roles)->toHaveCount(2); + expect($document->roles)->toHaveKey('editor'); + expect($document->roles)->toHaveKey('admin'); - $editor = collect($document->roles)->firstWhere('id', 'editor'); - expect($editor['name'])->toBe('Editor') - ->and($editor['permissions'])->toBe(['posts.create', 'posts.update']) - ->and($editor)->not->toHaveKey('system'); + expect($document->roles['editor']['permissions'])->toBe(['posts.create', 'posts.update']); + expect($document->roles['editor'])->not->toHaveKey('system'); - $admin = collect($document->roles)->firstWhere('id', 'admin'); - expect($admin['name'])->toBe('Admin') - ->and($admin['permissions'])->toHaveCount(3) - ->and($admin['system'])->toBeTrue(); + expect($document->roles['admin']['permissions'])->toHaveCount(3); + expect($document->roles['admin']['system'])->toBeTrue(); }); it('exports all assignments with correct serialization', function (): void { @@ -76,20 +78,20 @@ function seedFullState($context): void ->and($scopedAssignment['scope'])->toBe('team::5'); }); -it('exports all boundaries', function (): void { +it('exports all boundaries in v2 keyed format', function (): void { seedFullState($this); $document = $this->exporter->export(); - expect($document->boundaries)->toHaveCount(1) - ->and($document->boundaries[0]['scope'])->toBe('team::5') - ->and($document->boundaries[0]['max_permissions'])->toBe(['posts.create', 'posts.update']); + expect($document->boundaries)->toHaveCount(1); + expect($document->boundaries)->toHaveKey('team::5'); + expect($document->boundaries['team::5']['max_permissions'])->toBe(['posts.create', 'posts.update']); }); -it('returns version 1.0', function (): void { +it('returns version 2.0', function (): void { $document = $this->exporter->export(); - expect($document->version)->toBe('1.0'); + expect($document->version)->toBe('2.0'); }); // --- export scoped --- @@ -118,8 +120,8 @@ function seedFullState($context): void $document = $this->exporter->export('team::5'); - expect($document->roles)->toHaveCount(1) - ->and($document->roles[0]['id'])->toBe('admin'); + expect($document->roles)->toHaveCount(1); + expect($document->roles)->toHaveKey('admin'); }); it('exports only the boundary for the specified scope', function (): void { @@ -128,8 +130,8 @@ function seedFullState($context): void $document = $this->exporter->export('team::5'); - expect($document->boundaries)->toHaveCount(1) - ->and($document->boundaries[0]['scope'])->toBe('team::5'); + expect($document->boundaries)->toHaveCount(1); + expect($document->boundaries)->toHaveKey('team::5'); }); it('returns empty boundaries when scope has no boundary', function (): void { @@ -145,11 +147,12 @@ function seedFullState($context): void it('exports empty document when no data exists', function (): void { $document = $this->exporter->export(); - expect($document->version)->toBe('1.0') + expect($document->version)->toBe('2.0') ->and($document->permissions)->toBe([]) ->and($document->roles)->toBe([]) ->and($document->assignments)->toBe([]) - ->and($document->boundaries)->toBe([]); + ->and($document->boundaries)->toBe([]) + ->and($document->resourcePolicies)->toBe([]); }); it('exports empty scoped document when scope has no data', function (): void { @@ -163,6 +166,40 @@ function seedFullState($context): void ->and($document->boundaries)->toBe([]); }); +// --- resource policies export --- + +it('exports resource policies in the document', function (): void { + $statement = new PolicyStatement( + effect: Effect::Allow, + action: 'posts.read', + principalPattern: '*', + ); + $this->resourcePolicyStore->attach('post', null, $statement); + + $document = $this->exporter->export(); + + expect($document->resourcePolicies)->toHaveCount(1); + expect($document->resourcePolicies[0]['resource_type'])->toBe('post'); + expect($document->resourcePolicies[0]['effect'])->toBe('Allow'); + expect($document->resourcePolicies[0]['action'])->toBe('posts.read'); + expect($document->resourcePolicies[0]['principal_pattern'])->toBe('*'); +}); + +it('exports empty resource_policies when none exist', function (): void { + $document = $this->exporter->export(); + + expect($document->resourcePolicies)->toBe([]); +}); + +it('does not include resource policies in scoped export', function (): void { + $statement = new PolicyStatement(effect: Effect::Allow, action: 'posts.read'); + $this->resourcePolicyStore->attach('post', null, $statement); + + $document = $this->exporter->export('team::5'); + + expect($document->resourcePolicies)->toBe([]); +}); + // --- round-trip with importer --- it('produces a document that can be reimported identically', function (): void { @@ -188,7 +225,6 @@ function seedFullState($context): void $reExported = $this->exporter->export(); expect($reExported->permissions)->toBe($exported->permissions) - ->and($reExported->boundaries)->toBe($exported->boundaries) ->and(count($reExported->roles))->toBe(count($exported->roles)) ->and(count($reExported->assignments))->toBe(count($exported->assignments)); }); diff --git a/tests/Feature/DefaultDocumentImporterTest.php b/tests/Feature/DefaultDocumentImporterTest.php index 2eaee3a..20b1630 100644 --- a/tests/Feature/DefaultDocumentImporterTest.php +++ b/tests/Feature/DefaultDocumentImporterTest.php @@ -6,9 +6,12 @@ use DynamikDev\PolicyEngine\Contracts\BoundaryStore; use DynamikDev\PolicyEngine\Contracts\DocumentImporter; use DynamikDev\PolicyEngine\Contracts\PermissionStore; +use DynamikDev\PolicyEngine\Contracts\ResourcePolicyStore; use DynamikDev\PolicyEngine\Contracts\RoleStore; use DynamikDev\PolicyEngine\DTOs\ImportOptions; use DynamikDev\PolicyEngine\DTOs\PolicyDocument; +use DynamikDev\PolicyEngine\DTOs\PolicyStatement; +use DynamikDev\PolicyEngine\Enums\Effect; use DynamikDev\PolicyEngine\Events\AssignmentRevoked; use DynamikDev\PolicyEngine\Events\BoundaryRemoved; use DynamikDev\PolicyEngine\Events\DocumentImported; @@ -17,6 +20,7 @@ use DynamikDev\PolicyEngine\Models\Assignment; use DynamikDev\PolicyEngine\Models\Boundary; use DynamikDev\PolicyEngine\Models\Permission; +use DynamikDev\PolicyEngine\Models\ResourcePolicy; use DynamikDev\PolicyEngine\Models\Role; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -29,6 +33,7 @@ $this->roleStore = app(RoleStore::class); $this->assignmentStore = app(AssignmentStore::class); $this->boundaryStore = app(BoundaryStore::class); + $this->resourcePolicyStore = app(ResourcePolicyStore::class); $this->importer = app(DocumentImporter::class); }); @@ -453,3 +458,172 @@ function fullDocument(): PolicyDocument // Import events from the new data Event::assertDispatched(DocumentImported::class); }); + +// --- v2 format import --- + +it('imports a v2 document with roles keyed by id', function (): void { + $document = new PolicyDocument( + version: '2.0', + permissions: ['posts.create', 'posts.update'], + roles: [ + 'editor' => ['permissions' => ['posts.create', 'posts.update']], + 'admin' => ['permissions' => ['posts.create', 'posts.update'], 'system' => true], + ], + assignments: [], + boundaries: [ + 'team::5' => ['max_permissions' => ['posts.create']], + ], + ); + + $result = $this->importer->import($document, new ImportOptions); + + expect($result->rolesCreated)->toBe(['editor', 'admin']) + ->and($result->rolesUpdated)->toBe([]) + ->and($result->warnings)->toBe([]); + + expect(Role::query()->count())->toBe(2); + expect(Role::query()->find('admin')->is_system)->toBeTrue(); + expect(Role::query()->find('editor')->is_system)->toBeFalse(); + + expect($this->roleStore->permissionsFor('editor'))->toBe(['posts.create', 'posts.update']); + + $boundary = $this->boundaryStore->find('team::5'); + expect($boundary)->not->toBeNull(); + expect($boundary->max_permissions)->toBe(['posts.create']); +}); + +it('imports v2 roles in merge mode correctly', function (): void { + $this->roleStore->save('editor', 'Old Editor', ['posts.create']); + + $document = new PolicyDocument( + version: '2.0', + permissions: ['posts.create', 'posts.update'], + roles: [ + 'editor' => ['permissions' => ['posts.create', 'posts.update']], + 'admin' => ['permissions' => ['posts.create', 'posts.update']], + ], + ); + + $result = $this->importer->import($document, new ImportOptions(merge: true)); + + expect($result->rolesCreated)->toBe(['admin']) + ->and($result->rolesUpdated)->toBe(['editor']); +}); + +it('warns about unregistered permissions in v2 role definitions', function (): void { + $document = new PolicyDocument( + version: '2.0', + permissions: ['posts.create'], + roles: [ + 'editor' => ['permissions' => ['posts.create', 'posts.delete', 'unknown.perm']], + ], + ); + + $result = $this->importer->import($document, new ImportOptions); + + expect($result->warnings)->toHaveCount(2) + ->and($result->warnings[0])->toContain('posts.delete') + ->and($result->warnings[1])->toContain('unknown.perm'); +}); + +// --- resource policy import --- + +it('imports resource policies from the document', function (): void { + $document = new PolicyDocument( + version: '2.0', + permissions: [], + roles: [], + assignments: [], + boundaries: [], + resourcePolicies: [ + [ + 'resource_type' => 'post', + 'resource_id' => null, + 'effect' => 'Allow', + 'action' => 'posts.read', + 'principal_pattern' => '*', + 'conditions' => [], + ], + ], + ); + + $this->importer->import($document, new ImportOptions); + + expect(ResourcePolicy::query()->count())->toBe(1); + + $rp = ResourcePolicy::query()->first(); + expect($rp->resource_type)->toBe('post') + ->and($rp->resource_id)->toBeNull() + ->and($rp->effect)->toBe('Allow') + ->and($rp->action)->toBe('posts.read') + ->and($rp->principal_pattern)->toBe('*'); +}); + +it('does not import resource policies during dry run', function (): void { + $document = new PolicyDocument( + version: '2.0', + resourcePolicies: [ + [ + 'resource_type' => 'post', + 'resource_id' => null, + 'effect' => 'Allow', + 'action' => 'posts.read', + 'principal_pattern' => null, + 'conditions' => [], + ], + ], + ); + + $this->importer->import($document, new ImportOptions(dryRun: true)); + + expect(ResourcePolicy::query()->count())->toBe(0); +}); + +it('clears resource policies in replace mode', function (): void { + // Pre-seed a resource policy + $this->resourcePolicyStore->attach('post', null, new PolicyStatement( + effect: Effect::Allow, + action: 'posts.read', + )); + expect(ResourcePolicy::query()->count())->toBe(1); + + // Import in replace mode with no resource policies + $document = new PolicyDocument( + version: '2.0', + permissions: ['posts.create'], + roles: ['editor' => ['permissions' => ['posts.create']]], + ); + + $this->importer->import($document, new ImportOptions(merge: false)); + + expect(ResourcePolicy::query()->count())->toBe(0); +}); + +it('imports resource policies with conditions', function (): void { + $document = new PolicyDocument( + version: '2.0', + resourcePolicies: [ + [ + 'resource_type' => 'post', + 'resource_id' => '42', + 'effect' => 'Deny', + 'action' => 'posts.delete', + 'principal_pattern' => null, + 'conditions' => [ + ['type' => 'attribute_equals', 'parameters' => ['subject_key' => 'dept', 'resource_key' => 'dept']], + ], + ], + ], + ); + + $this->importer->import($document, new ImportOptions); + + expect(ResourcePolicy::query()->count())->toBe(1); + + $rp = ResourcePolicy::query()->first(); + expect($rp->resource_type)->toBe('post') + ->and($rp->resource_id)->toBe('42') + ->and($rp->effect)->toBe('Deny') + ->and($rp->conditions)->toHaveCount(1) + ->and($rp->conditions[0]['type'])->toBe('attribute_equals'); +}); diff --git a/tests/Feature/DefaultEvaluatorTest.php b/tests/Feature/DefaultEvaluatorTest.php index 6bf9550..237883d 100644 --- a/tests/Feature/DefaultEvaluatorTest.php +++ b/tests/Feature/DefaultEvaluatorTest.php @@ -2,946 +2,269 @@ declare(strict_types=1); -use DynamikDev\PolicyEngine\Contracts\AssignmentStore; -use DynamikDev\PolicyEngine\Contracts\BoundaryStore; use DynamikDev\PolicyEngine\Contracts\Matcher; -use DynamikDev\PolicyEngine\Contracts\PermissionStore; -use DynamikDev\PolicyEngine\Contracts\RoleStore; -use DynamikDev\PolicyEngine\DTOs\EvaluationTrace; -use DynamikDev\PolicyEngine\Enums\EvaluationResult; +use DynamikDev\PolicyEngine\Contracts\PolicyResolver; +use DynamikDev\PolicyEngine\DTOs\Context; +use DynamikDev\PolicyEngine\DTOs\EvaluationRequest; +use DynamikDev\PolicyEngine\DTOs\EvaluationResult; +use DynamikDev\PolicyEngine\DTOs\PolicyStatement; +use DynamikDev\PolicyEngine\DTOs\Principal; +use DynamikDev\PolicyEngine\DTOs\Resource; +use DynamikDev\PolicyEngine\Enums\Decision; +use DynamikDev\PolicyEngine\Enums\Effect; use DynamikDev\PolicyEngine\Evaluators\DefaultEvaluator; -use DynamikDev\PolicyEngine\Events\AuthorizationDenied; -use DynamikDev\PolicyEngine\Models\Assignment; -use DynamikDev\PolicyEngine\Models\Role; -use Illuminate\Foundation\Auth\User; -use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Event; -uses(RefreshDatabase::class); - -beforeEach(function (): void { - $this->permissionStore = app(PermissionStore::class); - $this->roleStore = app(RoleStore::class); - $this->assignmentStore = app(AssignmentStore::class); - $this->boundaryStore = app(BoundaryStore::class); - - $this->evaluator = new DefaultEvaluator( - assignments: $this->assignmentStore, - roles: $this->roleStore, - boundaries: $this->boundaryStore, - matcher: app(Matcher::class), +function makeRequest(string $action, ?Resource $resource = null, ?string $scope = null): EvaluationRequest +{ + $context = $scope !== null + ? new Context(scope: $scope) + : new Context; + + return new EvaluationRequest( + principal: new Principal(type: 'user', id: 1), + action: $action, + resource: $resource, + context: $context, ); -}); - -// --- can: basic allow --- - -it('allows a permission when the subject has a matching role', function (): void { - $this->permissionStore->register(['posts.create', 'posts.read']); - $this->roleStore->save('editor', 'Editor', ['posts.create', 'posts.read']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); - - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.create'))->toBeTrue(); -}); - -// --- can: basic deny --- - -it('denies a permission the subject does not have', function (): void { - $this->permissionStore->register(['posts.read']); - $this->roleStore->save('viewer', 'Viewer', ['posts.read']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'viewer'); - - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.delete'))->toBeFalse(); -}); - -// --- can: deny wins over allow --- - -it('denies when an explicit deny rule overrides an allow', function (): void { - $this->permissionStore->register(['posts.create', 'posts.delete']); - $this->roleStore->save('editor', 'Editor', ['posts.*']); - $this->roleStore->save('restricted', 'Restricted', ['!posts.delete']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); - $this->assignmentStore->assign('App\\Models\\User', 1, 'restricted'); - - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.create'))->toBeTrue() - ->and($this->evaluator->can('App\\Models\\User', 1, 'posts.delete'))->toBeFalse(); -}); - -// --- can: wildcard grants --- - -it('allows via wildcard permission match', function (): void { - $this->permissionStore->register(['posts.create', 'posts.read', 'posts.update']); - $this->roleStore->save('editor', 'Editor', ['posts.*']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); - - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.create'))->toBeTrue() - ->and($this->evaluator->can('App\\Models\\User', 1, 'posts.read'))->toBeTrue() - ->and($this->evaluator->can('App\\Models\\User', 1, 'posts.update'))->toBeTrue() - ->and($this->evaluator->can('App\\Models\\User', 1, 'comments.create'))->toBeFalse(); -}); - -// --- can: scoped evaluation --- - -it('evaluates permissions within a scope', function (): void { - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('team-editor', 'Team Editor', ['posts.create']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'team-editor', 'team::5'); - - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.create:team::5'))->toBeTrue(); -}); - -it('denies scoped permission when subject only has a different scope', function (): void { - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('team-editor', 'Team Editor', ['posts.create']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'team-editor', 'team::5'); - - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.create:team::99'))->toBeFalse(); -}); - -it('allows scoped permission via global (unscoped) assignment', function (): void { - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); - - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.create:team::5'))->toBeTrue(); -}); - -// --- can: boundary enforcement --- - -it('allows permission when within boundary', function (): void { - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor', 'org::acme'); - $this->boundaryStore->set('org::acme', ['posts.*']); - - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.create:org::acme'))->toBeTrue(); -}); - -it('denies permission when outside boundary', function (): void { - $this->permissionStore->register(['posts.create', 'billing.manage']); - $this->roleStore->save('admin', 'Admin', ['posts.create', 'billing.manage']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'admin', 'org::acme'); - $this->boundaryStore->set('org::acme', ['posts.*']); - - expect($this->evaluator->can('App\\Models\\User', 1, 'billing.manage:org::acme'))->toBeFalse(); -}); - -it('allows permission when no boundary exists for scope', function (): void { - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor', 'org::acme'); - - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.create:org::acme'))->toBeTrue(); -}); - -// --- can: no assignment means deny --- - -it('denies when the subject has no assignments', function (): void { - expect($this->evaluator->can('App\\Models\\User', 99, 'posts.create'))->toBeFalse(); -}); - -// --- can: AuthorizationDenied event --- - -it('dispatches AuthorizationDenied when denied and log_denials is true', function (): void { - Event::fake([AuthorizationDenied::class]); - config()->set('policy-engine.log_denials', true); - - $this->evaluator->can('App\\Models\\User', 1, 'posts.create'); - - Event::assertDispatched(AuthorizationDenied::class, function (AuthorizationDenied $event): bool { - return $event->subject === 'App\\Models\\User:1' - && $event->permission === 'posts.create' - && $event->scope === null; - }); -}); - -it('does not dispatch AuthorizationDenied when log_denials is false', function (): void { - Event::fake([AuthorizationDenied::class]); - config()->set('policy-engine.log_denials', false); - - $this->evaluator->can('App\\Models\\User', 1, 'posts.create'); - - Event::assertNotDispatched(AuthorizationDenied::class); -}); - -it('does not dispatch AuthorizationDenied when permission is allowed', function (): void { - Event::fake([AuthorizationDenied::class]); - config()->set('policy-engine.log_denials', true); - - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); - - $this->evaluator->can('App\\Models\\User', 1, 'posts.create'); - - Event::assertNotDispatched(AuthorizationDenied::class); -}); - -// --- explain --- - -it('throws RuntimeException when explain mode is disabled', function (): void { - config()->set('policy-engine.explain', false); - - $this->evaluator->explain('App\\Models\\User', 1, 'posts.create'); -})->throws(RuntimeException::class, 'Explain mode is disabled'); - -it('returns an EvaluationTrace when explain mode is enabled', function (): void { - config()->set('policy-engine.explain', true); - - $this->permissionStore->register(['posts.create', 'posts.read']); - $this->roleStore->save('editor', 'Editor', ['posts.create', 'posts.read']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); - - $trace = $this->evaluator->explain('App\\Models\\User', 1, 'posts.create'); - - expect($trace) - ->toBeInstanceOf(EvaluationTrace::class) - ->subject->toBe('App\\Models\\User:1') - ->required->toBe('posts.create') - ->result->toBe(EvaluationResult::Allow) - ->cacheHit->toBeFalse() - ->assignments->toHaveCount(1) - ->assignments->each->toHaveKeys(['role', 'scope', 'permissions_checked']); -}); - -it('returns deny trace when permission is not granted', function (): void { - config()->set('policy-engine.explain', true); - - $this->permissionStore->register(['posts.read']); - $this->roleStore->save('viewer', 'Viewer', ['posts.read']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'viewer'); - - $trace = $this->evaluator->explain('App\\Models\\User', 1, 'posts.delete'); - - expect($trace) - ->result->toBe(EvaluationResult::Deny) - ->boundary->toBeNull(); -}); - -it('includes boundary note in explain trace when boundary blocks', function (): void { - config()->set('policy-engine.explain', true); - - $this->permissionStore->register(['billing.manage']); - $this->roleStore->save('admin', 'Admin', ['billing.manage']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'admin', 'org::acme'); - $this->boundaryStore->set('org::acme', ['posts.*']); - - $trace = $this->evaluator->explain('App\\Models\\User', 1, 'billing.manage:org::acme'); - - expect($trace) - ->result->toBe(EvaluationResult::Deny) - ->boundary->toContain('Denied by boundary'); -}); - -// --- effectivePermissions --- - -it('returns all effective permissions for a subject', function (): void { - $this->permissionStore->register(['posts.create', 'posts.read', 'posts.delete']); - $this->roleStore->save('editor', 'Editor', ['posts.create', 'posts.read', 'posts.delete']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); - - $permissions = $this->evaluator->effectivePermissions('App\\Models\\User', 1); - - expect($permissions)->toEqualCanonicalizing(['posts.create', 'posts.read', 'posts.delete']); -}); - -it('excludes denied permissions from effective set', function (): void { - $this->permissionStore->register(['posts.create', 'posts.read', 'posts.delete']); - $this->roleStore->save('editor', 'Editor', ['posts.create', 'posts.read', 'posts.delete']); - $this->roleStore->save('restricted', 'Restricted', ['!posts.delete']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); - $this->assignmentStore->assign('App\\Models\\User', 1, 'restricted'); - - $permissions = $this->evaluator->effectivePermissions('App\\Models\\User', 1); - - expect($permissions)->toContain('posts.create', 'posts.read') - ->not->toContain('posts.delete'); -}); - -it('returns empty array when subject has no assignments', function (): void { - expect($this->evaluator->effectivePermissions('App\\Models\\User', 99))->toBe([]); -}); - -it('returns scoped effective permissions including global assignments', function (): void { - $this->permissionStore->register(['posts.create', 'posts.read', 'billing.manage']); - $this->roleStore->save('viewer', 'Viewer', ['posts.read']); - $this->roleStore->save('team-editor', 'Team Editor', ['posts.create']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'viewer'); - $this->assignmentStore->assign('App\\Models\\User', 1, 'team-editor', 'team::5'); - - $permissions = $this->evaluator->effectivePermissions('App\\Models\\User', 1, 'team::5'); - - expect($permissions)->toEqualCanonicalizing(['posts.read', 'posts.create']); -}); - -it('excludes permissions blocked by boundary from scoped effective set', function (): void { - $this->permissionStore->register(['posts.create', 'posts.read', 'billing.manage']); - $this->roleStore->save('admin', 'Admin', ['posts.create', 'posts.read', 'billing.manage']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'admin', 'team::5'); - $this->boundaryStore->set('team::5', ['posts.*']); - - $permissions = $this->evaluator->effectivePermissions('App\\Models\\User', 1, 'team::5'); - - expect($permissions)->toContain('posts.create', 'posts.read') - ->not->toContain('billing.manage'); -}); - -it('does not apply boundary filtering for unscoped effective permissions', function (): void { - $this->permissionStore->register(['posts.create', 'billing.manage']); - $this->roleStore->save('admin', 'Admin', ['posts.create', 'billing.manage']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'admin'); - $this->boundaryStore->set('team::5', ['posts.*']); - - $permissions = $this->evaluator->effectivePermissions('App\\Models\\User', 1); - - expect($permissions)->toContain('posts.create', 'billing.manage') - ->toHaveCount(2); -}); - -it('deduplicates permissions from multiple roles', function (): void { - $this->permissionStore->register(['posts.create', 'posts.read']); - $this->roleStore->save('editor', 'Editor', ['posts.create', 'posts.read']); - $this->roleStore->save('contributor', 'Contributor', ['posts.create']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); - $this->assignmentStore->assign('App\\Models\\User', 1, 'contributor'); - - $permissions = $this->evaluator->effectivePermissions('App\\Models\\User', 1); - - expect($permissions)->toHaveCount(2) - ->toContain('posts.create', 'posts.read'); -}); - -it('short-circuits before role permission lookups when boundary denies', function (): void { - $this->permissionStore->register(['billing.manage']); - $this->roleStore->save('admin', 'Admin', ['billing.manage']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'admin', 'org::acme'); - $this->boundaryStore->set('org::acme', ['posts.*']); - - $spyRoleStore = new class($this->roleStore) implements RoleStore +} + +/** + * @param PolicyStatement[] $statements + */ +function makeResolver(array $statements): PolicyResolver +{ + return new class($statements) implements PolicyResolver { - public int $singleCalls = 0; - - public int $batchCalls = 0; - - public function __construct( - private readonly RoleStore $inner, - ) {} - - public function save(string $id, string $name, array $permissions, bool $system = false): Role - { - return $this->inner->save($id, $name, $permissions, $system); - } - - public function remove(string $id): void - { - $this->inner->remove($id); - } - - public function removeAll(): void - { - $this->inner->removeAll(); - } - - public function find(string $id): ?Role - { - return $this->inner->find($id); - } - - public function findMany(array $ids): Collection - { - return $this->inner->findMany($ids); - } - - public function all(): Collection - { - return $this->inner->all(); - } - - public function permissionsFor(string $roleId): array - { - $this->singleCalls++; - - return $this->inner->permissionsFor($roleId); - } - - /** @param array $roleIds */ - public function permissionsForRoles(array $roleIds): array - { - $this->batchCalls++; + /** + * @param PolicyStatement[] $statements + */ + public function __construct(private readonly array $statements) {} - return $this->inner->permissionsForRoles($roleIds); - } - - public function saveDirectPermissionRole(string $permission, array $permissions): Role + public function resolve(EvaluationRequest $request): Collection { - return $this->inner->saveDirectPermissionRole($permission, $permissions); + return collect($this->statements); } }; +} + +// a) Default deny — no statements from resolvers → Deny with decidedBy='default-deny' +it('returns default deny when no resolvers produce statements', function (): void { $evaluator = new DefaultEvaluator( - assignments: $this->assignmentStore, - roles: $spyRoleStore, - boundaries: $this->boundaryStore, + resolvers: [makeResolver([])], matcher: app(Matcher::class), ); - expect($evaluator->can('App\\Models\\User', 1, 'billing.manage:org::acme'))->toBeFalse(); - expect($spyRoleStore->singleCalls)->toBe(0) - ->and($spyRoleStore->batchCalls)->toBe(0); -}); - -// --- explain: deny permission collected in trace --- + $result = $evaluator->evaluate(makeRequest('posts.create')); -it('collects deny permissions in explain trace', function (): void { - config()->set('policy-engine.explain', true); - - $this->permissionStore->register(['posts.create', 'posts.delete']); - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->roleStore->save('restricted', 'Restricted', ['!posts.delete']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); - $this->assignmentStore->assign('App\\Models\\User', 1, 'restricted'); - - $trace = $this->evaluator->explain('App\\Models\\User', 1, 'posts.create'); - - expect($trace) - ->result->toBe(EvaluationResult::Allow) - ->assignments->toHaveCount(2); - - // The restricted role's !posts.delete should appear in the permissions_checked - $restrictedAssignment = collect($trace->assignments)->firstWhere('role', 'restricted'); - expect($restrictedAssignment['permissions_checked'])->toContain('!posts.delete'); + expect($result)->toBeInstanceOf(EvaluationResult::class) + ->and($result->decision)->toBe(Decision::Deny) + ->and($result->decidedBy)->toBe('default-deny'); }); -// --- explain: deny_unbounded_scopes with missing boundary --- +// b) Allow — matching Allow statement → Allow with correct decidedBy -it('returns deny trace when deny_unbounded_scopes is enabled and no boundary exists', function (): void { - config()->set('policy-engine.explain', true); - config()->set('policy-engine.deny_unbounded_scopes', true); +it('returns allow when a matching Allow statement exists', function (): void { + $statement = new PolicyStatement( + effect: Effect::Allow, + action: 'posts.create', + source: 'role:editor', + ); - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor', 'org::acme'); + $evaluator = new DefaultEvaluator( + resolvers: [makeResolver([$statement])], + matcher: app(Matcher::class), + ); - $trace = $this->evaluator->explain('App\\Models\\User', 1, 'posts.create:org::acme'); + $result = $evaluator->evaluate(makeRequest('posts.create')); - expect($trace) - ->result->toBe(EvaluationResult::Deny) - ->boundary->toContain('Denied by missing boundary') - ->boundary->toContain('deny_unbounded_scopes'); + expect($result->decision)->toBe(Decision::Allow) + ->and($result->decidedBy)->toBe('role:editor'); }); -// --- explain: deny rule matches --- +// c) Deny wins — both Allow and Deny for same action → Deny wins -it('returns deny trace when a deny rule matches in explain', function (): void { - config()->set('policy-engine.explain', true); +it('returns deny when both Allow and Deny statements exist for the same action', function (): void { + $allow = new PolicyStatement(effect: Effect::Allow, action: 'posts.delete', source: 'role:editor'); + $deny = new PolicyStatement(effect: Effect::Deny, action: 'posts.delete', source: 'role:restricted'); - $this->permissionStore->register(['posts.create', 'posts.delete']); - $this->roleStore->save('editor', 'Editor', ['posts.*']); - $this->roleStore->save('restricted', 'Restricted', ['!posts.delete']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); - $this->assignmentStore->assign('App\\Models\\User', 1, 'restricted'); + $evaluator = new DefaultEvaluator( + resolvers: [makeResolver([$allow, $deny])], + matcher: app(Matcher::class), + ); - $trace = $this->evaluator->explain('App\\Models\\User', 1, 'posts.delete'); + $result = $evaluator->evaluate(makeRequest('posts.delete')); - expect($trace) - ->result->toBe(EvaluationResult::Deny) - ->boundary->toBeNull(); + expect($result->decision)->toBe(Decision::Deny) + ->and($result->decidedBy)->toBe('role:restricted'); }); -// --- fallback paths: store without batch methods --- - -it('works with custom assignment store implementing forSubjectGlobal', function (): void { - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('editor', 'Editor', ['posts.create']); - - // Use a minimal store that implements the full contract - $minimalAssignmentStore = new class implements AssignmentStore - { - /** @var array */ - private array $assignments = []; - - public function assign(string $subjectType, string|int $subjectId, string $roleId, ?string $scope = null): void - { - $assignment = new Assignment; - $assignment->subject_type = $subjectType; - $assignment->subject_id = $subjectId; - $assignment->role_id = $roleId; - $assignment->scope = $scope; - $this->assignments[] = $assignment; - } - - public function revoke(string $subjectType, string|int $subjectId, string $roleId, ?string $scope = null): void {} - - public function forSubject(string $subjectType, string|int $subjectId): Collection - { - return collect($this->assignments) - ->filter(fn ($a) => $a->subject_type === $subjectType && (string) $a->subject_id === (string) $subjectId) - ->values(); - } - - public function forSubjectInScope(string $subjectType, string|int $subjectId, string $scope): Collection - { - return collect(); - } - - public function forSubjectGlobal(string $subjectType, string|int $subjectId): Collection - { - return $this->forSubject($subjectType, $subjectId) - ->filter(fn ($a) => $a->scope === null) - ->values(); - } - - public function forSubjectGlobalAndScope(string $subjectType, string|int $subjectId, string $scope): Collection - { - return $this->forSubject($subjectType, $subjectId) - ->filter(fn ($a) => $a->scope === null || $a->scope === $scope) - ->values(); - } - - public function subjectsInScope(string $scope, ?string $roleId = null): Collection - { - return collect(); - } - - public function all(): Collection - { - return collect($this->assignments); - } - - public function removeAll(): void - { - $this->assignments = []; - } - }; +// d) Wildcard matching — `posts.*` matches `posts.create` - $minimalAssignmentStore->assign('App\\Models\\User', 1, 'editor'); - // Also add a scoped assignment that should be filtered out for global-only query - $minimalAssignmentStore->assign('App\\Models\\User', 1, 'editor', 'team::5'); +it('matches a wildcard action pattern against a specific action', function (): void { + $statement = new PolicyStatement( + effect: Effect::Allow, + action: 'posts.*', + source: 'role:editor', + ); $evaluator = new DefaultEvaluator( - assignments: $minimalAssignmentStore, - roles: $this->roleStore, - boundaries: $this->boundaryStore, + resolvers: [makeResolver([$statement])], matcher: app(Matcher::class), ); - // Unscoped check: should only use global assignments (scope === null) - expect($evaluator->can('App\\Models\\User', 1, 'posts.create'))->toBeTrue(); -}); - -it('works with custom assignment store implementing forSubjectGlobalAndScope', function (): void { - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('editor', 'Editor', ['posts.create']); - - $minimalAssignmentStore = new class implements AssignmentStore - { - /** @var array */ - private array $assignments = []; - - public function assign(string $subjectType, string|int $subjectId, string $roleId, ?string $scope = null): void - { - $assignment = new Assignment; - $assignment->subject_type = $subjectType; - $assignment->subject_id = $subjectId; - $assignment->role_id = $roleId; - $assignment->scope = $scope; - $this->assignments[] = $assignment; - } - - public function revoke(string $subjectType, string|int $subjectId, string $roleId, ?string $scope = null): void {} - - public function forSubject(string $subjectType, string|int $subjectId): Collection - { - return collect($this->assignments) - ->filter(fn ($a) => $a->subject_type === $subjectType && (string) $a->subject_id === (string) $subjectId) - ->values(); - } - - public function forSubjectInScope(string $subjectType, string|int $subjectId, string $scope): Collection - { - return collect(); - } - - public function forSubjectGlobal(string $subjectType, string|int $subjectId): Collection - { - return $this->forSubject($subjectType, $subjectId) - ->filter(fn ($a) => $a->scope === null) - ->values(); - } - - public function forSubjectGlobalAndScope(string $subjectType, string|int $subjectId, string $scope): Collection - { - return $this->forSubject($subjectType, $subjectId) - ->filter(fn ($a) => $a->scope === null || $a->scope === $scope) - ->values(); - } + $result = $evaluator->evaluate(makeRequest('posts.create')); - public function subjectsInScope(string $scope, ?string $roleId = null): Collection - { - return collect(); - } + expect($result->decision)->toBe(Decision::Allow) + ->and($result->decidedBy)->toBe('role:editor'); +}); - public function all(): Collection - { - return collect($this->assignments); - } +// e) Multiple resolvers — statements collected from all resolvers, deny from any source wins - public function removeAll(): void - { - $this->assignments = []; - } - }; +it('collects statements from all resolvers and deny from any source wins', function (): void { + $allowResolver = makeResolver([ + new PolicyStatement(effect: Effect::Allow, action: 'posts.publish', source: 'role:editor'), + ]); - $minimalAssignmentStore->assign('App\\Models\\User', 1, 'editor', 'team::5'); - $minimalAssignmentStore->assign('App\\Models\\User', 1, 'editor'); + $denyResolver = makeResolver([ + new PolicyStatement(effect: Effect::Deny, action: 'posts.publish', source: 'policy:content-lock'), + ]); $evaluator = new DefaultEvaluator( - assignments: $minimalAssignmentStore, - roles: $this->roleStore, - boundaries: $this->boundaryStore, + resolvers: [$allowResolver, $denyResolver], matcher: app(Matcher::class), ); - // Scoped check: should include both global and scope-matching assignments - expect($evaluator->can('App\\Models\\User', 1, 'posts.create:team::5'))->toBeTrue(); -}); - -it('works with custom role store implementing permissionsForRoles', function (): void { - $this->permissionStore->register(['posts.create']); - - $minimalRoleStore = new class implements RoleStore - { - /** @var array, system: bool}> */ - private array $roles = []; - - public function save(string $id, string $name, array $permissions, bool $system = false): Role - { - $this->roles[$id] = ['name' => $name, 'permissions' => $permissions, 'system' => $system]; - - $role = new Role; - $role->id = $id; - $role->name = $name; - $role->is_system = $system; - - return $role; - } - - public function remove(string $id): void - { - unset($this->roles[$id]); - } - - public function find(string $id): ?Role - { - if (! isset($this->roles[$id])) { - return null; - } - - $role = new Role; - $role->id = $id; - $role->name = $this->roles[$id]['name']; - $role->is_system = $this->roles[$id]['system']; - - return $role; - } - - public function findMany(array $ids): Collection - { - return collect($ids) - ->map(fn (string $id): ?Role => $this->find($id)) - ->filter() - ->keyBy('id'); - } - - public function all(): Collection - { - return collect(); - } - - public function permissionsFor(string $roleId): array - { - return $this->roles[$roleId]['permissions'] ?? []; - } + $result = $evaluator->evaluate(makeRequest('posts.publish')); - public function permissionsForRoles(array $roleIds): array - { - $result = []; - - foreach ($roleIds as $roleId) { - $result[$roleId] = $this->permissionsFor($roleId); - } - - return $result; - } - - public function removeAll(): void - { - $this->roles = []; - } - - public function saveDirectPermissionRole(string $permission, array $permissions): Role - { - return $this->save('__dp.'.$permission, "Direct: {$permission}", $permissions); - } - }; + expect($result->decision)->toBe(Decision::Deny) + ->and($result->decidedBy)->toBe('policy:content-lock'); +}); - $minimalRoleStore->save('editor', 'Editor', ['posts.create']); +// f) Unrelated actions — statements for different actions don't match → default deny - // Create role in DB so the Eloquent assignment store FK is satisfied. - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); +it('returns default deny when statements exist but none match the requested action', function (): void { + $statement = new PolicyStatement( + effect: Effect::Allow, + action: 'comments.create', + source: 'role:commenter', + ); $evaluator = new DefaultEvaluator( - assignments: $this->assignmentStore, - roles: $minimalRoleStore, - boundaries: $this->boundaryStore, + resolvers: [makeResolver([$statement])], matcher: app(Matcher::class), ); - expect($evaluator->can('App\\Models\\User', 1, 'posts.create'))->toBeTrue(); -}); - -// --- sanctumTokenAllows fallback paths --- - -it('allows when Sanctum class does not exist and permission is otherwise granted', function (): void { - /* - * Sanctum IS installed in dev, so this path (line 384) is not reachable - * in the test environment. Instead, test the user-mismatch path (line 398). - */ - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); - - /* - * Evaluating for a subject that doesn't match the authenticated user - * (no user authenticated at all — sanctumTokenAllows returns true at line 390) - */ - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.create'))->toBeTrue(); -}); - -it('allows when authenticated user does not match evaluated subject', function (): void { - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); - - // Create a fake authenticated user with a different ID - $fakeUser = new class extends User - { - protected $table = 'users'; - }; - $fakeUser->id = 999; - - $this->actingAs($fakeUser); + $result = $evaluator->evaluate(makeRequest('posts.create')); - /* - * Evaluating subject User:1 while authenticated as User:999 - * This hits line 398 (user doesn't match subject) and returns true - */ - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.create'))->toBeTrue(); + expect($result->decision)->toBe(Decision::Deny) + ->and($result->decidedBy)->toBe('default-deny'); }); -it('uses batched role permission loading when the role store supports it', function (): void { - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->roleStore->save('reviewer', 'Reviewer', ['posts.create']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); - $this->assignmentStore->assign('App\\Models\\User', 1, 'reviewer'); - - $spyRoleStore = new class($this->roleStore) implements RoleStore - { - public int $singleCalls = 0; - - public int $batchCalls = 0; - - public function __construct( - private readonly RoleStore $inner, - ) {} - - public function save(string $id, string $name, array $permissions, bool $system = false): Role - { - return $this->inner->save($id, $name, $permissions, $system); - } - - public function remove(string $id): void - { - $this->inner->remove($id); - } - - public function removeAll(): void - { - $this->inner->removeAll(); - } - - public function find(string $id): ?Role - { - return $this->inner->find($id); - } - - public function all(): Collection - { - return $this->inner->all(); - } - - public function permissionsFor(string $roleId): array - { - $this->singleCalls++; - - return $this->inner->permissionsFor($roleId); - } - - public function findMany(array $ids): Collection - { - return $this->inner->findMany($ids); - } +// g) Trace enabled — when `config('policy-engine.trace')` is true, matchedStatements populated - /** @param array $roleIds */ - public function permissionsForRoles(array $roleIds): array - { - $this->batchCalls++; - - return $this->inner->permissionsForRoles($roleIds); - } +it('populates matchedStatements when trace is enabled', function (): void { + config(['policy-engine.trace' => true]); - public function saveDirectPermissionRole(string $permission, array $permissions): Role - { - return $this->inner->saveDirectPermissionRole($permission, $permissions); - } - }; + $statement = new PolicyStatement( + effect: Effect::Allow, + action: 'posts.create', + source: 'role:editor', + ); $evaluator = new DefaultEvaluator( - assignments: $this->assignmentStore, - roles: $spyRoleStore, - boundaries: $this->boundaryStore, + resolvers: [makeResolver([$statement])], matcher: app(Matcher::class), ); - expect($evaluator->can('App\\Models\\User', 1, 'posts.create'))->toBeTrue(); - expect($spyRoleStore->batchCalls)->toBe(1) - ->and($spyRoleStore->singleCalls)->toBe(0); -}); - -// --- enforce_boundaries_on_global --- - -it('allows global permission when enforce_boundaries_on_global is disabled (default)', function (): void { - config()->set('policy-engine.enforce_boundaries_on_global', false); - - $this->permissionStore->register(['billing.manage']); - $this->roleStore->save('admin', 'Admin', ['*.*']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'admin'); - $this->boundaryStore->set('team::5', ['posts.*']); - - expect($this->evaluator->can('App\\Models\\User', 1, 'billing.manage'))->toBeTrue(); -}); - -it('denies global permission blocked by all boundaries when enforce_boundaries_on_global is enabled', function (): void { - config()->set('policy-engine.enforce_boundaries_on_global', true); - - $this->permissionStore->register(['billing.manage']); - $this->roleStore->save('admin', 'Admin', ['*.*']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'admin'); - $this->boundaryStore->set('team::5', ['posts.*']); + $result = $evaluator->evaluate(makeRequest('posts.create')); - expect($this->evaluator->can('App\\Models\\User', 1, 'billing.manage'))->toBeFalse(); + expect($result->matchedStatements)->toHaveCount(1) + ->and($result->matchedStatements[0])->toBe($statement); }); -it('allows global permission matching at least one boundary when enforce_boundaries_on_global is enabled', function (): void { - config()->set('policy-engine.enforce_boundaries_on_global', true); +// h) Trace disabled — when trace is false, matchedStatements is empty array - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('admin', 'Admin', ['*.*']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'admin'); - $this->boundaryStore->set('team::5', ['posts.*']); - $this->boundaryStore->set('org::acme', ['billing.*']); +it('leaves matchedStatements empty when trace is disabled', function (): void { + config(['policy-engine.trace' => false]); - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.create'))->toBeTrue(); -}); + $statement = new PolicyStatement( + effect: Effect::Allow, + action: 'posts.create', + source: 'role:editor', + ); -it('allows global permission when enforce_boundaries_on_global is enabled but no boundaries exist', function (): void { - config()->set('policy-engine.enforce_boundaries_on_global', true); + $evaluator = new DefaultEvaluator( + resolvers: [makeResolver([$statement])], + matcher: app(Matcher::class), + ); - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); + $result = $evaluator->evaluate(makeRequest('posts.create')); - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.create'))->toBeTrue(); + expect($result->matchedStatements)->toBe([]); }); -it('explain returns deny trace with global boundary note when enforce_boundaries_on_global blocks', function (): void { - config()->set('policy-engine.explain', true); - config()->set('policy-engine.enforce_boundaries_on_global', true); - - $this->permissionStore->register(['billing.manage']); - $this->roleStore->save('admin', 'Admin', ['*.*']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'admin'); - $this->boundaryStore->set('team::5', ['posts.*']); - - $trace = $this->evaluator->explain('App\\Models\\User', 1, 'billing.manage'); - - expect($trace) - ->result->toBe(EvaluationResult::Deny) - ->boundary->toContain('global boundary enforcement'); -}); +// i) Principal pattern matching — statement with `principalPattern: 'user:1'` matches principal type:user id:1 -it('explain returns allow trace with global boundary note when enforce_boundaries_on_global passes', function (): void { - config()->set('policy-engine.explain', true); - config()->set('policy-engine.enforce_boundaries_on_global', true); +it('applies a statement when principalPattern matches the request principal', function (): void { + $statement = new PolicyStatement( + effect: Effect::Allow, + action: 'posts.create', + principalPattern: 'user:1', + source: 'policy:explicit', + ); - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('admin', 'Admin', ['*.*']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'admin'); - $this->boundaryStore->set('team::5', ['posts.*']); + $evaluator = new DefaultEvaluator( + resolvers: [makeResolver([$statement])], + matcher: app(Matcher::class), + ); - $trace = $this->evaluator->explain('App\\Models\\User', 1, 'posts.create'); + $result = $evaluator->evaluate(makeRequest('posts.create')); - expect($trace) - ->result->toBe(EvaluationResult::Allow) - ->boundary->toContain('global boundary enforcement'); + expect($result->decision)->toBe(Decision::Allow) + ->and($result->decidedBy)->toBe('policy:explicit'); }); -it('filters effective permissions by global boundaries when enforce_boundaries_on_global is enabled', function (): void { - config()->set('policy-engine.enforce_boundaries_on_global', true); +// j) Principal pattern rejection — statement with `principalPattern: 'user:999'` doesn't match principal type:user id:1 - $this->permissionStore->register(['posts.create', 'billing.manage']); - $this->roleStore->save('admin', 'Admin', ['posts.create', 'billing.manage']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'admin'); - $this->boundaryStore->set('team::5', ['posts.*']); - - $permissions = $this->evaluator->effectivePermissions('App\\Models\\User', 1); - - expect($permissions)->toContain('posts.create') - ->not->toContain('billing.manage'); -}); +it('skips a statement when principalPattern does not match the request principal', function (): void { + $statement = new PolicyStatement( + effect: Effect::Allow, + action: 'posts.create', + principalPattern: 'user:999', + source: 'policy:other-user', + ); -// --- Empty boundary max_permissions --- + $evaluator = new DefaultEvaluator( + resolvers: [makeResolver([$statement])], + matcher: app(Matcher::class), + ); -it('denies all permissions when boundary has empty max_permissions array', function (): void { - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor', 'org::acme'); - $this->boundaryStore->set('org::acme', []); + $result = $evaluator->evaluate(makeRequest('posts.create')); - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.create:org::acme'))->toBeFalse(); + expect($result->decision)->toBe(Decision::Deny) + ->and($result->decidedBy)->toBe('default-deny'); }); -// --- explain: early deny when no assignments --- - -it('explain returns deny trace immediately when subject has no assignments', function (): void { - config()->set('policy-engine.explain', true); - - $trace = $this->evaluator->explain('App\\Models\\User', 99, 'posts.create'); - - expect($trace->result)->toBe(EvaluationResult::Deny) - ->and($trace->assignments)->toBeEmpty(); -}); +// k) Resource pattern matching — statement with `resourcePattern: 'post:42'` matches resource type:post id:42 -// --- sanctumTokenAllows: no authenticated user --- +it('applies a statement when resourcePattern matches the request resource', function (): void { + $statement = new PolicyStatement( + effect: Effect::Allow, + action: 'posts.edit', + resourcePattern: 'post:42', + source: 'policy:resource-grant', + ); -it('allows when no user is authenticated and permission is otherwise granted', function (): void { - auth()->logout(); + $evaluator = new DefaultEvaluator( + resolvers: [makeResolver([$statement])], + matcher: app(Matcher::class), + ); - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'editor'); + $resource = new Resource(type: 'post', id: 42); + $result = $evaluator->evaluate(makeRequest('posts.edit', $resource)); - expect($this->evaluator->can('App\\Models\\User', 1, 'posts.create'))->toBeTrue(); + expect($result->decision)->toBe(Decision::Allow) + ->and($result->decidedBy)->toBe('policy:resource-grant'); }); diff --git a/tests/Feature/DocumentRoundTripTest.php b/tests/Feature/DocumentRoundTripTest.php index bbf8298..94831bd 100644 --- a/tests/Feature/DocumentRoundTripTest.php +++ b/tests/Feature/DocumentRoundTripTest.php @@ -8,12 +8,16 @@ use DynamikDev\PolicyEngine\Contracts\DocumentImporter; use DynamikDev\PolicyEngine\Contracts\DocumentParser; use DynamikDev\PolicyEngine\Contracts\PermissionStore; +use DynamikDev\PolicyEngine\Contracts\ResourcePolicyStore; use DynamikDev\PolicyEngine\Contracts\RoleStore; use DynamikDev\PolicyEngine\DTOs\ImportOptions; use DynamikDev\PolicyEngine\DTOs\PolicyDocument; +use DynamikDev\PolicyEngine\DTOs\PolicyStatement; +use DynamikDev\PolicyEngine\Enums\Effect; use DynamikDev\PolicyEngine\Models\Assignment; use DynamikDev\PolicyEngine\Models\Boundary; use DynamikDev\PolicyEngine\Models\Permission; +use DynamikDev\PolicyEngine\Models\ResourcePolicy; use DynamikDev\PolicyEngine\Models\Role; use DynamikDev\PolicyEngine\Models\RolePermission; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -25,6 +29,7 @@ $this->roleStore = app(RoleStore::class); $this->assignmentStore = app(AssignmentStore::class); $this->boundaryStore = app(BoundaryStore::class); + $this->resourcePolicyStore = app(ResourcePolicyStore::class); $this->exporter = app(DocumentExporter::class); $this->importer = app(DocumentImporter::class); $this->parser = app(DocumentParser::class); @@ -35,6 +40,7 @@ */ function clearDatabase(): void { + ResourcePolicy::query()->delete(); Assignment::query()->delete(); RolePermission::query()->delete(); Boundary::query()->delete(); @@ -57,16 +63,23 @@ function clearDatabase(): void // Export the full state. $originalExport = $this->exporter->export(); + // Exporter now outputs v2 format. + expect($originalExport->version)->toBe('2.0'); + expect($originalExport->roles)->toHaveKey('editor'); + expect($originalExport->roles)->toHaveKey('admin'); + expect($originalExport->boundaries)->toHaveKey('org::acme'); + // Serialize to JSON and parse back. $json = $this->parser->serialize($originalExport); $parsed = $this->parser->parse($json); - // Verify the parsed document matches the original export. - expect($parsed->version)->toBe($originalExport->version) - ->and($parsed->permissions)->toBe($originalExport->permissions) - ->and($parsed->roles)->toBe($originalExport->roles) - ->and($parsed->assignments)->toBe($originalExport->assignments) - ->and($parsed->boundaries)->toBe($originalExport->boundaries); + // After parse, roles and boundaries are normalized to v1-compatible indexed format. + expect($parsed->version)->toBe('2.0'); + expect($parsed->permissions)->toBe($originalExport->permissions); + expect($parsed->assignments)->toBe($originalExport->assignments); + + // Verify role count + expect($parsed->roles)->toHaveCount(2); // Clear the database completely. clearDatabase(); @@ -83,24 +96,23 @@ function clearDatabase(): void // Re-export and verify all sections match the original. $reExported = $this->exporter->export(); - expect($reExported->version)->toBe($originalExport->version) - ->and($reExported->permissions)->toBe($originalExport->permissions) - ->and($reExported->boundaries)->toBe($originalExport->boundaries); + expect($reExported->version)->toBe('2.0'); + expect($reExported->permissions)->toBe($originalExport->permissions); - // Verify roles match (order-independent). - expect($reExported->roles)->toHaveCount(count($originalExport->roles)); + // Boundaries keyed by scope — both should have the same keys + expect(array_keys($reExported->boundaries))->toEqualCanonicalizing(array_keys($originalExport->boundaries)); - foreach ($originalExport->roles as $originalRole) { - $reExportedRole = collect($reExported->roles)->firstWhere('id', $originalRole['id']); - expect($reExportedRole)->not->toBeNull() - ->and($reExportedRole['name'])->toBe($originalRole['name']) - ->and($reExportedRole['permissions'])->toBe($originalRole['permissions']); + // Verify roles match (order-independent via keys in v2 format). + expect($reExported->roles)->toHaveCount(count($originalExport->roles)); - if (isset($originalRole['system'])) { - expect($reExportedRole['system'])->toBe($originalRole['system']); - } + foreach (array_keys($originalExport->roles) as $roleId) { + expect($reExported->roles)->toHaveKey($roleId); + expect($reExported->roles[$roleId]['permissions'])->toBe($originalExport->roles[$roleId]['permissions']); } + // Verify system flag preserved on admin + expect($reExported->roles['admin']['system'])->toBeTrue(); + // Verify assignments match (order-independent). expect($reExported->assignments)->toHaveCount(count($originalExport->assignments)); @@ -138,11 +150,11 @@ function clearDatabase(): void expect($scopedExport->permissions)->toEqualCanonicalizing(['posts.create', 'posts.delete', 'comments.create']) ->and($scopedExport->assignments)->toHaveCount(2) ->and($scopedExport->boundaries)->toHaveCount(1) - ->and($scopedExport->boundaries[0]['scope'])->toBe('team::5'); + ->and($scopedExport->boundaries)->toHaveKey('team::5'); // Only the moderator role should be present (only role assigned in team::5). - expect($scopedExport->roles)->toHaveCount(1) - ->and($scopedExport->roles[0]['id'])->toBe('moderator'); + expect($scopedExport->roles)->toHaveCount(1); + expect($scopedExport->roles)->toHaveKey('moderator'); // All scoped assignments reference team::5. foreach ($scopedExport->assignments as $assignment) { @@ -200,3 +212,80 @@ function clearDatabase(): void expect(Assignment::query()->count())->toBe(0) ->and(Boundary::query()->count())->toBe(0); }); + +// --- v2 round-trip with resource policies --- + +it('round-trips resource policies through export, serialize, parse, and import', function (): void { + $this->permissionStore->register(['posts.read']); + + $statement = new PolicyStatement( + effect: Effect::Allow, + action: 'posts.read', + principalPattern: '*', + ); + $this->resourcePolicyStore->attach('post', null, $statement); + + // Export → serialize → parse → clear → import + $exported = $this->exporter->export(); + expect($exported->resourcePolicies)->toHaveCount(1); + + $json = $this->parser->serialize($exported); + clearDatabase(); + + $parsed = $this->parser->parse($json); + expect($parsed->resourcePolicies)->toHaveCount(1); + + $this->importer->import($parsed, new ImportOptions(merge: false)); + + expect(ResourcePolicy::query()->count())->toBe(1); + + $rp = ResourcePolicy::query()->first(); + expect($rp->resource_type)->toBe('post') + ->and($rp->resource_id)->toBeNull() + ->and($rp->effect)->toBe('Allow') + ->and($rp->action)->toBe('posts.read') + ->and($rp->principal_pattern)->toBe('*'); +}); + +// --- v2 document parsed from JSON string round-trip --- + +it('parses a v2 JSON document and imports it correctly', function (): void { + $json = json_encode([ + 'version' => '2.0', + 'permissions' => ['posts.create', 'posts.read'], + 'roles' => [ + 'editor' => ['permissions' => ['posts.create', 'posts.read']], + ], + 'assignments' => [ + ['subject' => 'App\Models\User::1', 'role' => 'editor'], + ], + 'boundaries' => [ + 'free-tier' => ['max_permissions' => ['posts.read']], + ], + 'resource_policies' => [ + [ + 'resource_type' => 'post', + 'resource_id' => null, + 'effect' => 'Allow', + 'action' => 'posts.read', + 'principal_pattern' => '*', + 'conditions' => [], + ], + ], + ]); + + $parser = app(DocumentParser::class); + $document = $parser->parse($json); + + $this->importer->import($document, new ImportOptions(merge: false)); + + expect(Permission::query()->count())->toBe(2); + expect(Role::query()->count())->toBe(1); + expect(Role::query()->find('editor'))->not->toBeNull(); + expect(Assignment::query()->count())->toBe(1); + expect(Boundary::query()->count())->toBe(1); + expect(ResourcePolicy::query()->count())->toBe(1); + + expect($this->roleStore->permissionsFor('editor'))->toBe(['posts.create', 'posts.read']); + expect($this->boundaryStore->find('free-tier'))->not->toBeNull(); +}); diff --git a/tests/Feature/EloquentResourcePolicyStoreTest.php b/tests/Feature/EloquentResourcePolicyStoreTest.php new file mode 100644 index 0000000..e9c1621 --- /dev/null +++ b/tests/Feature/EloquentResourcePolicyStoreTest.php @@ -0,0 +1,170 @@ +store = app(ResourcePolicyStore::class); +}); + +it('attaches and retrieves a policy for a specific resource instance', function (): void { + $statement = new PolicyStatement( + effect: Effect::Allow, + action: 'documents.read', + principalPattern: null, + resourcePattern: null, + conditions: [], + source: '', + ); + + $this->store->attach('App\\Models\\Document', 42, $statement); + + $results = $this->store->forResource('App\\Models\\Document', 42); + + expect($results)->toHaveCount(1); + + $result = $results->first(); + expect($result->effect)->toBe(Effect::Allow) + ->and($result->action)->toBe('documents.read') + ->and($result->source)->toBe('resource:App\\Models\\Document:42'); +}); + +it('attaches and retrieves a type-level policy with null resource id', function (): void { + $statement = new PolicyStatement( + effect: Effect::Deny, + action: 'documents.delete', + principalPattern: null, + resourcePattern: null, + conditions: [], + source: '', + ); + + $this->store->attach('App\\Models\\Document', null, $statement); + + $results = $this->store->forResource('App\\Models\\Document', null); + + expect($results)->toHaveCount(1); + + $result = $results->first(); + expect($result->effect)->toBe(Effect::Deny) + ->and($result->action)->toBe('documents.delete') + ->and($result->source)->toBe('resource:App\\Models\\Document'); +}); + +it('forResource returns both type-level and instance-level policies together', function (): void { + $typeLevelStatement = new PolicyStatement( + effect: Effect::Deny, + action: 'documents.delete', + principalPattern: null, + resourcePattern: null, + conditions: [], + source: '', + ); + + $instanceStatement = new PolicyStatement( + effect: Effect::Allow, + action: 'documents.read', + principalPattern: null, + resourcePattern: null, + conditions: [], + source: '', + ); + + $this->store->attach('App\\Models\\Document', null, $typeLevelStatement); + $this->store->attach('App\\Models\\Document', 7, $instanceStatement); + + $results = $this->store->forResource('App\\Models\\Document', 7); + + expect($results)->toHaveCount(2); + + $actions = $results->pluck('action')->sort()->values()->all(); + expect($actions)->toBe(['documents.delete', 'documents.read']); +}); + +it('detaches a policy by action for a specific resource instance', function (): void { + $statement = new PolicyStatement( + effect: Effect::Allow, + action: 'documents.read', + principalPattern: null, + resourcePattern: null, + conditions: [], + source: '', + ); + + $this->store->attach('App\\Models\\Document', 10, $statement); + + $before = $this->store->forResource('App\\Models\\Document', 10); + expect($before)->toHaveCount(1); + + $this->store->detach('App\\Models\\Document', 10, 'documents.read'); + + $after = $this->store->forResource('App\\Models\\Document', 10); + expect($after)->toBeEmpty(); +}); + +it('detach with null id removes only type-level policies', function (): void { + $typeLevelStatement = new PolicyStatement( + effect: Effect::Deny, + action: 'documents.delete', + principalPattern: null, + resourcePattern: null, + conditions: [], + source: '', + ); + + $instanceStatement = new PolicyStatement( + effect: Effect::Deny, + action: 'documents.delete', + principalPattern: null, + resourcePattern: null, + conditions: [], + source: '', + ); + + $this->store->attach('App\\Models\\Document', null, $typeLevelStatement); + $this->store->attach('App\\Models\\Document', 5, $instanceStatement); + + $this->store->detach('App\\Models\\Document', null, 'documents.delete'); + + // Instance-level policy should remain + $results = $this->store->forResource('App\\Models\\Document', 5); + $actions = $results->pluck('action')->all(); + + // Type-level is gone; instance-level stays + expect($results->filter(fn ($s) => $s->source === 'resource:App\\Models\\Document'))->toBeEmpty() + ->and($actions)->toContain('documents.delete'); +}); + +it('stores and hydrates conditions correctly', function (): void { + $statement = new PolicyStatement( + effect: Effect::Allow, + action: 'documents.read', + principalPattern: null, + resourcePattern: null, + conditions: [ + new Condition(type: 'attribute_equals', parameters: ['attribute' => 'department_id', 'value' => 1]), + ], + source: '', + ); + + $this->store->attach('App\\Models\\Document', 20, $statement); + + $results = $this->store->forResource('App\\Models\\Document', 20); + + expect($results)->toHaveCount(1); + + $result = $results->first(); + expect($result->conditions)->toHaveCount(1); + + $condition = $result->conditions[0]; + expect($condition)->toBeInstanceOf(Condition::class) + ->and($condition->type)->toBe('attribute_equals') + ->and($condition->parameters)->toEqualCanonicalizing(['attribute' => 'department_id', 'value' => 1]); +}); diff --git a/tests/Feature/EvaluationPipelineTest.php b/tests/Feature/EvaluationPipelineTest.php index 95f8243..faad5c1 100644 --- a/tests/Feature/EvaluationPipelineTest.php +++ b/tests/Feature/EvaluationPipelineTest.php @@ -6,13 +6,14 @@ use DynamikDev\PolicyEngine\Contracts\BoundaryStore; use DynamikDev\PolicyEngine\Contracts\PermissionStore; use DynamikDev\PolicyEngine\Contracts\RoleStore; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Schema; uses(RefreshDatabase::class); -class PipelineTestUser extends \Illuminate\Database\Eloquent\Model +class PipelineTestUser extends Model { use HasPermissions; @@ -101,13 +102,16 @@ class PipelineTestUser extends \Illuminate\Database\Eloquent\Model }); it('denies when boundary restricts wildcard permissions in scope', function (): void { - $this->permissionStore->register(['posts.create', 'members.invite']); - $this->roleStore->save('super', 'Super', ['*.*']); - $this->user->assign('super', 'org::acme'); + $this->permissionStore->register(['posts.create', 'posts.delete', 'billing.manage']); + $this->roleStore->save('admin', 'Admin', ['*.*']); + $this->user->assign('admin', 'org::acme'); $this->boundaryStore->set('org::acme', ['posts.*']); - expect($this->user->canDo('posts.create', 'org::acme'))->toBeTrue() - ->and($this->user->canDo('members.invite', 'org::acme'))->toBeFalse(); + // posts.create is within boundary ceiling. + expect($this->user->canDo('posts.create', 'org::acme'))->toBeTrue(); + + // billing.manage is outside the boundary ceiling — should be denied. + expect($this->user->canDo('billing.manage', 'org::acme'))->toBeFalse(); }); it('allows when permission is within boundary ceiling', function (): void { diff --git a/tests/Feature/GateIntegrationTest.php b/tests/Feature/GateIntegrationTest.php index befe8fe..f1ed1cf 100644 --- a/tests/Feature/GateIntegrationTest.php +++ b/tests/Feature/GateIntegrationTest.php @@ -3,243 +3,123 @@ declare(strict_types=1); use DynamikDev\PolicyEngine\Concerns\HasPermissions; -use DynamikDev\PolicyEngine\Concerns\Scopeable; +use DynamikDev\PolicyEngine\Concerns\HasResourcePolicies; use DynamikDev\PolicyEngine\Contracts\AssignmentStore; use DynamikDev\PolicyEngine\Contracts\PermissionStore; use DynamikDev\PolicyEngine\Contracts\RoleStore; +use DynamikDev\PolicyEngine\DTOs\Resource; +use Illuminate\Auth\Authenticatable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; -use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Schema; uses(RefreshDatabase::class); -class GateTestUser extends Authenticatable +class GateTestUser extends Model implements Illuminate\Contracts\Auth\Authenticatable { + use Authenticatable; use HasPermissions; - protected $table = 'gate_test_users'; + protected $table = 'users'; - protected $guarded = []; -} - -class GateTestUserWithoutTrait extends Authenticatable -{ - protected $table = 'gate_test_users'; + public $timestamps = false; protected $guarded = []; } -class GateTestTeam extends Model +class GateTestPost extends Model { - use Scopeable; + use HasResourcePolicies; - protected string $scopeType = 'team'; - - protected $table = 'gate_test_teams'; - - protected $guarded = []; -} + protected $table = 'posts'; -/** - * A policy that always denies — proves the Gate hook defers to it. - */ -class GateTestAlwaysDenyPolicy -{ - public function view(GateTestUser $user, GateTestTeam $team): bool - { - return false; - } -} + protected $fillable = ['title', 'status']; -/** - * A policy that calls canDo() internally — proves engine + policy composition. - */ -class GateTestCanDoPolicy -{ - public function update(GateTestUser $user, GateTestTeam $team): bool - { - return $user->canDo('teams.update', $team); - } + public $timestamps = false; } beforeEach(function (): void { - Schema::create('gate_test_users', function (Blueprint $table): void { + Schema::create('users', function (Blueprint $table): void { $table->id(); $table->string('name'); - $table->timestamps(); }); - Schema::create('gate_test_teams', function (Blueprint $table): void { + Schema::create('posts', function (Blueprint $table): void { $table->id(); - $table->string('name'); - $table->timestamps(); + $table->string('title'); + $table->string('status')->default('draft'); }); $this->permissionStore = app(PermissionStore::class); $this->roleStore = app(RoleStore::class); $this->assignmentStore = app(AssignmentStore::class); - $this->user = GateTestUser::query()->create(['name' => 'Alice']); -}); - -afterEach(function (): void { - Schema::dropIfExists('gate_test_teams'); - Schema::dropIfExists('gate_test_users'); -}); - -// --- Basic Gate integration --- - -it('allows $user->can() for a dot-notated permission the user has', function (): void { - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->user->assign('editor'); - - expect($this->user->can('posts.create'))->toBeTrue(); -}); - -it('denies $user->cannot() for a permission the user lacks', function (): void { - expect($this->user->cannot('posts.delete'))->toBeTrue(); -}); - -// --- Scoped permissions --- - -it('allows $user->can() with a scopeable model argument', function (): void { - $team = GateTestTeam::query()->create(['name' => 'Team A']); - - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('team-editor', 'Team Editor', ['posts.create']); - $this->user->assign('team-editor', $team); - - expect($this->user->can('posts.create', $team))->toBeTrue(); -}); - -it('allows $user->can() with a string scope argument', function (): void { - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('team-editor', 'Team Editor', ['posts.create']); - $this->user->assign('team-editor', 'team::5'); - - expect($this->user->can('posts.create', 'team::5'))->toBeTrue(); -}); - -// --- Deny rules --- + $this->permissionStore->register(['posts.create', 'posts.update', 'posts.delete']); + $this->roleStore->save('editor', 'Editor', ['posts.create', 'posts.update']); -it('enforces deny rules through the Gate', function (): void { - $this->permissionStore->register(['posts.create', 'posts.delete']); - $this->roleStore->save('editor', 'Editor', ['posts.create', 'posts.delete', '!posts.delete']); + $this->user = GateTestUser::query()->create(['name' => 'Alice']); $this->user->assign('editor'); - expect($this->user->can('posts.create'))->toBeTrue() - ->and($this->user->can('posts.delete'))->toBeFalse(); -}); - -// --- Non-dot abilities are not intercepted --- - -it('does not intercept non-dot abilities so Gate::define still works', function (): void { - Gate::define('admin-dashboard', fn (): bool => true); - $this->actingAs($this->user); - - expect($this->user->can('admin-dashboard'))->toBeTrue(); }); -// --- Wildcard permissions --- - -it('supports wildcard permissions through the Gate', function (): void { - $this->permissionStore->register(['posts.create', 'posts.update', 'posts.delete']); - $this->roleStore->save('super-editor', 'Super Editor', ['posts.*']); - $this->user->assign('super-editor'); - - expect($this->user->can('posts.create'))->toBeTrue() - ->and($this->user->can('posts.update'))->toBeTrue() - ->and($this->user->can('posts.delete'))->toBeTrue(); +afterEach(function (): void { + Schema::dropIfExists('posts'); + Schema::dropIfExists('users'); }); -// --- @can Blade directive --- - -it('renders @can Blade directive correctly with gate integration', function (): void { - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->user->assign('editor'); +// --- Basic dot-notated Gate integration --- - $this->actingAs($this->user); - - $html = trim(Blade::render(<<<'BLADE' - @can("posts.create") - VISIBLE - @endcan - BLADE)); +it('allows a dot-notated permission the user has through Gate', function (): void { + expect(Gate::allows('posts.create'))->toBeTrue(); +}); - expect($html)->toBe('VISIBLE'); +it('denies a dot-notated permission the user lacks through Gate', function (): void { + expect(Gate::allows('posts.delete'))->toBeFalse(); }); -// --- User without HasPermissions trait --- +// --- Scope as first argument (v1 compat) --- -it('abstains for user models without HasPermissions trait', function (): void { - $userWithoutTrait = GateTestUserWithoutTrait::query()->create(['name' => 'Bob']); +it('accepts a string scope as the first argument for scoped permissions', function (): void { + $this->user->revoke('editor'); + $this->user->assign('editor', 'team::5'); - // Without trait and without a Gate definition, should deny - expect($userWithoutTrait->cannot('posts.create'))->toBeTrue(); + expect(Gate::allows('posts.create', ['team::5']))->toBeTrue(); }); -// --- gate_passthrough config --- - -it('skips passthrough abilities so other Gate definitions can handle them', function (): void { - config()->set('policy-engine.gate_passthrough', ['admin.panel']); - - Gate::define('admin.panel', fn (): bool => true); +// --- Resource model as single argument --- - $this->actingAs($this->user); +it('accepts a model with HasResourcePolicies trait as a single argument', function (): void { + $post = GateTestPost::query()->create(['title' => 'Hello', 'status' => 'draft']); - expect($this->user->can('admin.panel'))->toBeTrue(); + expect(Gate::allows('posts.update', [$post]))->toBeTrue(); }); -// --- Two-lane authorization: dot-notated = engine, non-dot = policy --- - -it('handles dot-notated abilities through the engine even when a policy exists', function (): void { - Gate::policy(GateTestTeam::class, GateTestAlwaysDenyPolicy::class); +// --- Scope + resource as two arguments --- - $team = GateTestTeam::query()->create(['name' => 'Team A']); +it('accepts scope as first and resource model as second argument', function (): void { + $this->user->revoke('editor'); + $this->user->assign('editor', 'team::5'); - $this->permissionStore->register(['teams.view']); - $this->roleStore->save('viewer', 'Viewer', ['teams.view']); - $this->user->assign('viewer', $team); - - $this->actingAs($this->user); + $post = GateTestPost::query()->create(['title' => 'Hello', 'status' => 'draft']); - // Dot-notated: engine handles it, policy is not consulted. - expect($this->user->can('teams.view', $team))->toBeTrue(); + expect(Gate::allows('posts.update', ['team::5', $post]))->toBeTrue(); }); -it('defers non-dot abilities to policies that call canDo internally', function (): void { - Gate::policy(GateTestTeam::class, GateTestCanDoPolicy::class); +// --- Resource DTO directly --- - $team = GateTestTeam::query()->create(['name' => 'Team A']); +it('accepts a Resource DTO as a single argument', function (): void { + $resource = new Resource(type: 'post', id: 1, attributes: ['title' => 'Hello']); - $this->permissionStore->register(['teams.update']); - $this->roleStore->save('team-admin', 'Team Admin', ['teams.update']); - $this->user->assign('team-admin', $team); - - $this->actingAs($this->user); - - // Non-dot 'update': policy handles it, calls canDo() internally. - expect($this->user->can('update', $team))->toBeTrue(); + expect(Gate::allows('posts.update', [$resource]))->toBeTrue(); }); -it('policy calling canDo denies when engine denies', function (): void { - Gate::policy(GateTestTeam::class, GateTestCanDoPolicy::class); - - $team = GateTestTeam::query()->create(['name' => 'Team A']); - - $this->permissionStore->register(['teams.update']); - $this->roleStore->save('viewer', 'Viewer', ['teams.view']); - $this->user->assign('viewer'); +// --- Non-dot abilities are not intercepted --- - $this->actingAs($this->user); +it('does not intercept non-dot abilities', function (): void { + Gate::define('viewDashboard', fn (): bool => false); - // Policy calls canDo('teams.update', $team) — user lacks permission. - expect($this->user->can('update', $team))->toBeFalse(); + expect(Gate::allows('viewDashboard'))->toBeFalse(); }); diff --git a/tests/Feature/HasPermissionsTraitTest.php b/tests/Feature/HasPermissionsTraitTest.php index 79cb801..4eb753f 100644 --- a/tests/Feature/HasPermissionsTraitTest.php +++ b/tests/Feature/HasPermissionsTraitTest.php @@ -4,14 +4,23 @@ use DynamikDev\PolicyEngine\Concerns\HasPermissions; use DynamikDev\PolicyEngine\Contracts\AssignmentStore; +use DynamikDev\PolicyEngine\Contracts\Evaluator; +use DynamikDev\PolicyEngine\Contracts\Matcher; use DynamikDev\PolicyEngine\Contracts\PermissionStore; use DynamikDev\PolicyEngine\Contracts\RoleStore; -use DynamikDev\PolicyEngine\DTOs\EvaluationTrace; -use DynamikDev\PolicyEngine\Enums\EvaluationResult; +use DynamikDev\PolicyEngine\DTOs\EvaluationResult; +use DynamikDev\PolicyEngine\DTOs\Principal; +use DynamikDev\PolicyEngine\DTOs\Resource; +use DynamikDev\PolicyEngine\Enums\Decision; +use DynamikDev\PolicyEngine\Evaluators\CachedEvaluator; +use DynamikDev\PolicyEngine\Evaluators\DefaultEvaluator; +use DynamikDev\PolicyEngine\Resolvers\IdentityPolicyResolver; +use DynamikDev\PolicyEngine\Support\CacheStoreResolver; +use Illuminate\Cache\CacheManager; +use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Collection; use Illuminate\Support\Facades\Schema; uses(RefreshDatabase::class); @@ -19,214 +28,194 @@ /** * A minimal Eloquent model for testing the HasPermissions trait. */ -class TestUser extends Model +class HasPermissionsTestUser extends Model implements Authenticatable { use HasPermissions; + use Illuminate\Auth\Authenticatable; - protected $table = 'test_users'; + protected $table = 'users'; + + public $timestamps = false; protected $guarded = []; + + protected function principalAttributes(): array + { + return ['department_id' => 42]; + } } beforeEach(function (): void { - Schema::create('test_users', function (Blueprint $table): void { + Schema::create('users', function (Blueprint $table): void { $table->id(); $table->string('name'); - $table->timestamps(); + $table->string('email')->unique(); + $table->string('password'); + }); + + /* Wire up the v2 evaluator with IdentityPolicyResolver so canDo works. */ + app()->singleton(Evaluator::class, function ($app): CachedEvaluator { + return new CachedEvaluator( + inner: new DefaultEvaluator( + resolvers: [new IdentityPolicyResolver( + assignments: $app->make(AssignmentStore::class), + roles: $app->make(RoleStore::class), + )], + matcher: $app->make(Matcher::class), + ), + cache: $app->make(CacheManager::class), + ); }); + // Set the resolver chain for effectivePermissions(). + config(['policy-engine.resolvers' => [IdentityPolicyResolver::class]]); + $this->permissionStore = app(PermissionStore::class); $this->roleStore = app(RoleStore::class); $this->assignmentStore = app(AssignmentStore::class); - $this->user = TestUser::query()->create(['name' => 'Alice']); -}); + $this->permissionStore->register(['posts.create', 'posts.read', 'posts.update', 'posts.delete']); + $this->roleStore->save('editor', 'Editor', ['posts.create', 'posts.read', 'posts.update']); -afterEach(function (): void { - Schema::dropIfExists('test_users'); + $this->user = HasPermissionsTestUser::query()->create([ + 'name' => 'Alice', + 'email' => 'alice@example.com', + 'password' => 'secret', + ]); }); -// --- canDo --- - -it('returns true when the subject has the permission', function (): void { - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->user->assign('editor'); - - expect($this->user->canDo('posts.create'))->toBeTrue(); +afterEach(function (): void { + Schema::dropIfExists('users'); + CacheStoreResolver::reset(); }); -it('returns false when the subject lacks the permission', function (): void { - $this->permissionStore->register(['posts.read']); - $this->roleStore->save('viewer', 'Viewer', ['posts.read']); - $this->user->assign('viewer'); - - expect($this->user->canDo('posts.delete'))->toBeFalse(); -}); +// --- toPrincipal --- -it('evaluates scoped permissions via canDo', function (): void { - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('team-editor', 'Team Editor', ['posts.create']); - $this->user->assign('team-editor', 'team::5'); +it('toPrincipal returns a Principal with correct type, id, and attributes', function (): void { + $principal = $this->user->toPrincipal(); - expect($this->user->canDo('posts.create', 'team::5'))->toBeTrue() - ->and($this->user->canDo('posts.create', 'team::99'))->toBeFalse(); + expect($principal)->toBeInstanceOf(Principal::class) + ->and($principal->type)->toBe(HasPermissionsTestUser::class) + ->and($principal->id)->toBe($this->user->getKey()) + ->and($principal->attributes)->toBe(['department_id' => 42]); }); -// --- cannotDo --- - -it('returns true for cannotDo when permission is missing', function (): void { - expect($this->user->cannotDo('posts.delete'))->toBeTrue(); -}); +// --- canDo --- -it('returns false for cannotDo when permission is granted', function (): void { - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('editor', 'Editor', ['posts.create']); +it('canDo returns true when user has permission via role', function (): void { $this->user->assign('editor'); - expect($this->user->cannotDo('posts.create'))->toBeFalse(); + expect($this->user->canDo('posts.create'))->toBeTrue(); }); -// --- assign / revoke --- - -it('assigns a role to the subject', function (): void { - $this->roleStore->save('editor', 'Editor', ['posts.create']); +it('canDo returns false when user lacks permission', function (): void { $this->user->assign('editor'); - expect($this->user->assignments())->toHaveCount(1) - ->and($this->user->assignments()->first()->role_id)->toBe('editor'); -}); - -it('assigns a scoped role to the subject', function (): void { - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->user->assign('editor', 'team::5'); - - expect($this->user->assignmentsFor('team::5'))->toHaveCount(1) - ->and($this->user->assignmentsFor('team::5')->first()->scope)->toBe('team::5'); + expect($this->user->canDo('posts.delete'))->toBeFalse(); }); -it('revokes a role from the subject', function (): void { - $this->roleStore->save('editor', 'Editor', ['posts.create']); +it('canDo accepts an optional Resource parameter', function (): void { $this->user->assign('editor'); - expect($this->user->assignments())->toHaveCount(1); + $resource = new Resource(type: 'post', id: 99); - $this->user->revoke('editor'); - - expect($this->user->assignments())->toHaveCount(0); + expect($this->user->canDo('posts.create', null, resource: $resource))->toBeTrue(); }); -it('revokes a scoped role from the subject', function (): void { - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->user->assign('editor', 'team::5'); +it('canDo accepts an optional environment parameter', function (): void { $this->user->assign('editor'); - $this->user->revoke('editor', 'team::5'); - - expect($this->user->assignments())->toHaveCount(1) - ->and($this->user->assignments()->first()->scope)->toBeNull(); + expect($this->user->canDo('posts.create', null, environment: ['ip' => '127.0.0.1']))->toBeTrue(); }); -// --- assignments / assignmentsFor --- - -it('returns all assignments for the subject', function (): void { - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->roleStore->save('admin', 'Admin', ['admin.access']); - $this->user->assign('editor'); - $this->user->assign('admin', 'org::acme'); +// --- cannotDo --- - expect($this->user->assignments())->toHaveCount(2); +it('cannotDo returns true when permission is missing', function (): void { + expect($this->user->cannotDo('posts.delete'))->toBeTrue(); }); -it('returns assignments filtered by scope', function (): void { - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->roleStore->save('admin', 'Admin', ['admin.access']); +it('cannotDo returns false when permission is granted', function (): void { $this->user->assign('editor'); - $this->user->assign('admin', 'org::acme'); - expect($this->user->assignmentsFor('org::acme'))->toHaveCount(1) - ->and($this->user->assignmentsFor('org::acme')->first()->role_id)->toBe('admin'); + expect($this->user->cannotDo('posts.create'))->toBeFalse(); }); -// --- getRoles / getRolesFor --- +// --- explain --- -it('returns unique roles for the subject', function (): void { - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->roleStore->save('admin', 'Admin', ['admin.access']); +it('explain returns an EvaluationResult', function (): void { $this->user->assign('editor'); - $this->user->assign('admin'); - $roles = $this->user->getRoles(); + $result = $this->user->explain('posts.create'); - expect($roles)->toHaveCount(2) - ->and($roles->pluck('id')->sort()->values()->all())->toBe(['admin', 'editor']); + expect($result)->toBeInstanceOf(EvaluationResult::class) + ->and($result->decision)->toBe(Decision::Allow); }); -it('deduplicates roles across scopes', function (): void { - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->user->assign('editor'); - $this->user->assign('editor', 'team::5'); +it('explain returns a Deny result when permission is not granted', function (): void { + $result = $this->user->explain('posts.delete'); - expect($this->user->getRoles())->toHaveCount(1) - ->and($this->user->getRoles()->first()->id)->toBe('editor'); + expect($result)->toBeInstanceOf(EvaluationResult::class) + ->and($result->decision)->toBe(Decision::Deny); }); -it('returns roles filtered by scope', function (): void { - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->roleStore->save('admin', 'Admin', ['admin.access']); +// --- assign / revoke --- + +it('assign then canDo returns true, revoke then canDo returns false', function (): void { $this->user->assign('editor'); - $this->user->assign('admin', 'org::acme'); - $roles = $this->user->getRolesFor('org::acme'); + expect($this->user->canDo('posts.create'))->toBeTrue(); - expect($roles)->toHaveCount(1) - ->and($roles->first()->id)->toBe('admin'); -}); + $this->user->revoke('editor'); -it('returns empty collection when subject has no roles', function (): void { - expect($this->user->getRoles())->toHaveCount(0) - ->and($this->user->getRoles())->toBeInstanceOf(Collection::class); + // Flush the evaluator cache so the next check hits the store. + app()->forgetInstance(Evaluator::class); + app()->singleton(Evaluator::class, function ($app): CachedEvaluator { + return new CachedEvaluator( + inner: new DefaultEvaluator( + resolvers: [new IdentityPolicyResolver( + assignments: $app->make(AssignmentStore::class), + roles: $app->make(RoleStore::class), + )], + matcher: $app->make(Matcher::class), + ), + cache: $app->make(CacheManager::class), + ); + }); + + expect($this->user->canDo('posts.create'))->toBeFalse(); }); // --- hasRole --- -it('returns true when user has the role (unscoped)', function (): void { - $this->roleStore->save('editor', 'Editor', ['posts.create']); +it('hasRole returns true when user has the role (unscoped)', function (): void { $this->user->assign('editor'); expect($this->user->hasRole('editor'))->toBeTrue(); }); -it('returns false when user does not have the role', function (): void { - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->user->assign('editor'); - - expect($this->user->hasRole('admin'))->toBeFalse(); +it('hasRole returns false when user does not have the role', function (): void { + expect($this->user->hasRole('editor'))->toBeFalse(); }); -it('returns true when user has role in the given scope', function (): void { - $this->roleStore->save('editor', 'Editor', ['posts.create']); +it('hasRole returns true when user has role in the given scope', function (): void { $this->user->assign('editor', 'team::5'); expect($this->user->hasRole('editor', 'team::5'))->toBeTrue(); }); -it('returns false when user has role in a different scope', function (): void { - $this->roleStore->save('editor', 'Editor', ['posts.create']); +it('hasRole returns false when user has role in a different scope', function (): void { $this->user->assign('editor', 'team::5'); expect($this->user->hasRole('editor', 'team::99'))->toBeFalse(); }); -it('returns false for unscoped hasRole when role is only scoped', function (): void { - $this->roleStore->save('editor', 'Editor', ['posts.create']); +it('hasRole returns false for unscoped check when role is only scoped', function (): void { $this->user->assign('editor', 'team::5'); expect($this->user->hasRole('editor'))->toBeFalse(); }); -it('returns false for scoped hasRole when role is only global', function (): void { - $this->roleStore->save('editor', 'Editor', ['posts.create']); +it('hasRole returns false for scoped check when role is only global', function (): void { $this->user->assign('editor'); expect($this->user->hasRole('editor', 'team::5'))->toBeFalse(); @@ -234,308 +223,28 @@ class TestUser extends Model // --- effectivePermissions --- -it('returns all effective permissions for the subject', function (): void { - $this->permissionStore->register(['posts.create', 'posts.read', 'posts.delete']); - $this->roleStore->save('editor', 'Editor', ['posts.create', 'posts.read', 'posts.delete']); +it('effectivePermissions returns allowed permission strings', function (): void { $this->user->assign('editor'); - expect($this->user->effectivePermissions()) - ->toContain('posts.create', 'posts.read', 'posts.delete') - ->toHaveCount(3); -}); - -it('excludes denied permissions from effective set', function (): void { - $this->permissionStore->register(['posts.create', 'posts.read', 'posts.delete']); - $this->roleStore->save('editor', 'Editor', ['posts.create', 'posts.read', 'posts.delete']); - $this->roleStore->save('restricted', 'Restricted', ['!posts.delete']); - $this->user->assign('editor'); - $this->user->assign('restricted'); + $permissions = $this->user->effectivePermissions(); - expect($this->user->effectivePermissions()) - ->toContain('posts.create', 'posts.read') + expect($permissions) + ->toContain('posts.create', 'posts.read', 'posts.update') ->not->toContain('posts.delete'); }); -it('returns scoped effective permissions', function (): void { - $this->permissionStore->register(['posts.create', 'posts.read']); - $this->roleStore->save('viewer', 'Viewer', ['posts.read']); - $this->roleStore->save('team-editor', 'Team Editor', ['posts.create']); - $this->user->assign('viewer'); - $this->user->assign('team-editor', 'team::5'); - - expect($this->user->effectivePermissions('team::5')) - ->toContain('posts.read', 'posts.create'); -}); - -it('returns empty array when subject has no assignments', function (): void { - expect($this->user->effectivePermissions())->toBe([]); -}); - -// --- explain --- - -it('returns an EvaluationTrace via explain', function (): void { - config()->set('policy-engine.explain', true); - - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->user->assign('editor'); - - $trace = $this->user->explain('posts.create'); - - expect($trace) - ->toBeInstanceOf(EvaluationTrace::class) - ->subject->toBe(TestUser::class.':'.$this->user->getKey()) - ->required->toBe('posts.create') - ->result->toBe(EvaluationResult::Allow); -}); - -it('returns deny trace when permission is not granted', function (): void { - config()->set('policy-engine.explain', true); - - $trace = $this->user->explain('posts.delete'); - - expect($trace) - ->toBeInstanceOf(EvaluationTrace::class) - ->result->toBe(EvaluationResult::Deny); -}); - -it('returns scoped explain trace', function (): void { - config()->set('policy-engine.explain', true); - - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('team-editor', 'Team Editor', ['posts.create']); - $this->user->assign('team-editor', 'team::5'); - - $trace = $this->user->explain('posts.create', 'team::5'); - - expect($trace) - ->result->toBe(EvaluationResult::Allow) - ->assignments->toHaveCount(1); -}); - -it('throws RuntimeException when explain mode is disabled', function (): void { - config()->set('policy-engine.explain', false); - - $this->user->explain('posts.create'); -})->throws(RuntimeException::class); - -// --- wildcard permissions --- - -it('supports wildcard permissions via canDo', function (): void { - $this->permissionStore->register(['posts.create', 'posts.read', 'posts.update']); - $this->roleStore->save('editor', 'Editor', ['posts.*']); - $this->user->assign('editor'); - - expect($this->user->canDo('posts.create'))->toBeTrue() - ->and($this->user->canDo('posts.read'))->toBeTrue() - ->and($this->user->canDo('comments.create'))->toBeFalse(); -}); - -// --- deny wins over allow --- - -it('denies when an explicit deny rule overrides an allow', function (): void { - $this->permissionStore->register(['posts.create', 'posts.delete']); - $this->roleStore->save('editor', 'Editor', ['posts.*']); - $this->roleStore->save('restricted', 'Restricted', ['!posts.delete']); +it('effectivePermissions excludes permissions covered by deny statements', function (): void { + $this->roleStore->save('restricted', 'Restricted', ['!posts.update']); $this->user->assign('editor'); $this->user->assign('restricted'); - expect($this->user->canDo('posts.create'))->toBeTrue() - ->and($this->user->canDo('posts.delete'))->toBeFalse(); -}); - -// --- givePermission / revokePermission --- - -it('gives a permission directly without an explicit role', function (): void { - $this->permissionStore->register(['posts.create']); - - $this->user->givePermission('posts.create'); - - expect($this->user->canDo('posts.create'))->toBeTrue(); -}); - -it('revokes a directly given permission', function (): void { - $this->permissionStore->register(['posts.create']); - - $this->user->givePermission('posts.create'); - expect($this->user->canDo('posts.create'))->toBeTrue(); - - $this->user->revokePermission('posts.create'); - expect($this->user->canDo('posts.create'))->toBeFalse(); -}); - -it('throws when giving an unregistered permission', function (): void { - $this->user->givePermission('nonexistent.perm'); -})->throws(InvalidArgumentException::class, 'not registered'); - -it('hides direct permission roles from getRoles', function (): void { - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('editor', 'Editor', ['posts.create']); - - $this->user->assign('editor'); - $this->user->givePermission('posts.create'); - - $roles = $this->user->getRoles(); - - expect($roles)->toHaveCount(1) - ->and($roles->first()->id)->toBe('editor'); -}); - -it('gives a scoped direct permission', function (): void { - $this->permissionStore->register(['posts.create']); - - $this->user->givePermission('posts.create', 'team::5'); - - expect($this->user->canDo('posts.create', 'team::5'))->toBeTrue() - ->and($this->user->canDo('posts.create'))->toBeFalse(); -}); - -// --- syncRoles --- - -it('syncs roles by revoking removed and assigning new', function (): void { - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->roleStore->save('viewer', 'Viewer', ['posts.read']); - $this->roleStore->save('admin', 'Admin', ['*.*']); - - $this->user->assign('editor'); - $this->user->assign('viewer'); - - $this->user->syncRoles(['viewer', 'admin']); - - expect($this->user->hasRole('editor'))->toBeFalse() - ->and($this->user->hasRole('viewer'))->toBeTrue() - ->and($this->user->hasRole('admin'))->toBeTrue(); -}); - -it('syncs scoped roles without affecting global assignments', function (): void { - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->roleStore->save('viewer', 'Viewer', ['posts.read']); - $this->roleStore->save('admin', 'Admin', ['*.*']); - - $this->user->assign('admin'); - $this->user->assign('editor', 'team::5'); - - $this->user->syncRoles(['viewer'], 'team::5'); - - // Global admin untouched. - expect($this->user->hasRole('admin'))->toBeTrue(); - // Scoped editor replaced with viewer. - expect($this->user->hasRole('editor', 'team::5'))->toBeFalse() - ->and($this->user->hasRole('viewer', 'team::5'))->toBeTrue(); -}); - -it('syncRoles does not revoke direct permission roles', function (): void { - $this->permissionStore->register(['posts.create']); - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->roleStore->save('viewer', 'Viewer', ['posts.read']); - - $this->user->givePermission('posts.create'); - $this->user->assign('editor'); - - $this->user->syncRoles(['viewer']); - - expect($this->user->canDo('posts.create'))->toBeTrue() - ->and($this->user->hasRole('editor'))->toBeFalse() - ->and($this->user->hasRole('viewer'))->toBeTrue(); -}); - -it('syncs to empty array revokes all roles in scope', function (): void { - $this->roleStore->save('editor', 'Editor', ['posts.create']); - - $this->user->assign('editor'); - - $this->user->syncRoles([]); - - expect($this->user->hasRole('editor'))->toBeFalse(); -}); - -// --- hasAnyRole / hasAllRoles --- - -it('hasAnyRole returns true when user has at least one role', function (): void { - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->roleStore->save('admin', 'Admin', ['*.*']); - - $this->user->assign('editor'); - - expect($this->user->hasAnyRole(['admin', 'editor']))->toBeTrue(); -}); - -it('hasAnyRole returns false when user has none of the roles', function (): void { - $this->roleStore->save('editor', 'Editor', ['posts.create']); - - $this->user->assign('editor'); - - expect($this->user->hasAnyRole(['admin', 'super']))->toBeFalse(); -}); - -it('hasAllRoles returns true when user has every role', function (): void { - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->roleStore->save('viewer', 'Viewer', ['posts.read']); - - $this->user->assign('editor'); - $this->user->assign('viewer'); - - expect($this->user->hasAllRoles(['editor', 'viewer']))->toBeTrue(); -}); - -it('hasAllRoles returns false when user is missing one role', function (): void { - $this->roleStore->save('editor', 'Editor', ['posts.create']); - $this->roleStore->save('viewer', 'Viewer', ['posts.read']); - - $this->user->assign('editor'); - - expect($this->user->hasAllRoles(['editor', 'viewer']))->toBeFalse(); -}); - -it('hasAnyRole works with scope', function (): void { - $this->roleStore->save('editor', 'Editor', ['posts.create']); - - $this->user->assign('editor', 'team::5'); - - expect($this->user->hasAnyRole(['editor'], 'team::5'))->toBeTrue() - ->and($this->user->hasAnyRole(['editor'], 'team::99'))->toBeFalse(); -}); + $permissions = $this->user->effectivePermissions(); -it('hasAllRoles returns false for an empty array', function (): void { - expect($this->user->hasAllRoles([]))->toBeFalse(); + expect($permissions) + ->toContain('posts.create', 'posts.read') + ->not->toContain('posts.update'); }); -// --- getRoles query efficiency --- - -it('fetches multiple roles with a single batch query instead of N+1', function (): void { - $permissions = ['posts.create', 'posts.read', 'posts.update', 'posts.delete', 'comments.create']; - $this->permissionStore->register($permissions); - - $this->roleStore->save('role-a', 'Role A', ['posts.create']); - $this->roleStore->save('role-b', 'Role B', ['posts.read']); - $this->roleStore->save('role-c', 'Role C', ['posts.update']); - $this->roleStore->save('role-d', 'Role D', ['posts.delete']); - $this->roleStore->save('role-e', 'Role E', ['comments.create']); - - $this->user->assign('role-a'); - $this->user->assign('role-b'); - $this->user->assign('role-c'); - $this->user->assign('role-d'); - $this->user->assign('role-e'); - - // Reset query log to only capture getRoles() queries. - $connection = TestUser::query()->getConnection(); - $connection->enableQueryLog(); - $connection->flushQueryLog(); - - $roles = $this->user->getRoles(); - - $queries = $connection->getQueryLog(); - $connection->disableQueryLog(); - - expect($roles)->toHaveCount(5) - ->and($roles->pluck('id')->sort()->values()->all()) - ->toBe(['role-a', 'role-b', 'role-c', 'role-d', 'role-e']) - ->and($queries)->toHaveCount(2); // 1 for assignments, 1 for roles (whereIn) +it('effectivePermissions returns an empty array when user has no assignments', function (): void { + expect($this->user->effectivePermissions())->toBe([]); }); - -// --- assignmentsFor null scope --- - -it('throws InvalidArgumentException when assignmentsFor receives a null scope', function (): void { - $this->user->assignmentsFor(null); -})->throws(InvalidArgumentException::class, 'Scope could not be resolved.'); diff --git a/tests/Feature/HasResourcePoliciesTraitTest.php b/tests/Feature/HasResourcePoliciesTraitTest.php new file mode 100644 index 0000000..ede977f --- /dev/null +++ b/tests/Feature/HasResourcePoliciesTraitTest.php @@ -0,0 +1,141 @@ +id(); + $table->string('title'); + $table->unsignedBigInteger('owner_id'); + }); + + $this->store = app(ResourcePolicyStore::class); + + $this->document = ResourcePoliciesTestDocument::query()->create([ + 'title' => 'Quarterly Report', + 'owner_id' => 1, + ]); +}); + +afterEach(function (): void { + Schema::dropIfExists('documents'); +}); + +// --- toPolicyResource --- + +it('toPolicyResource returns a Resource with the correct type and id', function (): void { + $resource = $this->document->toPolicyResource(); + + expect($resource)->toBeInstanceOf(Resource::class) + ->and($resource->type)->toBe(ResourcePoliciesTestDocument::class) + ->and($resource->id)->toBe($this->document->getKey()); +}); + +it('toPolicyResource includes fillable attributes', function (): void { + $resource = $this->document->toPolicyResource(); + + expect($resource->attributes)->toHaveKey('title', 'Quarterly Report') + ->and($resource->attributes)->toHaveKey('owner_id', 1); +}); + +// --- attachPolicy --- + +it('attachPolicy attaches a policy via the store', function (): void { + $statement = new PolicyStatement( + effect: Effect::Allow, + action: 'documents.read', + principalPattern: null, + resourcePattern: null, + conditions: [], + source: '', + ); + + $this->document->attachPolicy($statement); + + $results = $this->store->forResource( + ResourcePoliciesTestDocument::class, + $this->document->getKey(), + ); + + expect($results)->toHaveCount(1) + ->and($results->first()->action)->toBe('documents.read') + ->and($results->first()->effect)->toBe(Effect::Allow); +}); + +// --- detachPolicy --- + +it('detachPolicy removes a policy from the store', function (): void { + $statement = new PolicyStatement( + effect: Effect::Allow, + action: 'documents.read', + principalPattern: null, + resourcePattern: null, + conditions: [], + source: '', + ); + + $this->document->attachPolicy($statement); + + $before = $this->store->forResource(ResourcePoliciesTestDocument::class, $this->document->getKey()); + expect($before)->toHaveCount(1); + + $this->document->detachPolicy('documents.read'); + + $after = $this->store->forResource(ResourcePoliciesTestDocument::class, $this->document->getKey()); + expect($after)->toBeEmpty(); +}); + +it('detachPolicy only removes the specified action', function (): void { + $readStatement = new PolicyStatement( + effect: Effect::Allow, + action: 'documents.read', + principalPattern: null, + resourcePattern: null, + conditions: [], + source: '', + ); + + $writeStatement = new PolicyStatement( + effect: Effect::Allow, + action: 'documents.write', + principalPattern: null, + resourcePattern: null, + conditions: [], + source: '', + ); + + $this->document->attachPolicy($readStatement); + $this->document->attachPolicy($writeStatement); + + $this->document->detachPolicy('documents.read'); + + $results = $this->store->forResource(ResourcePoliciesTestDocument::class, $this->document->getKey()); + + expect($results)->toHaveCount(1) + ->and($results->first()->action)->toBe('documents.write'); +}); diff --git a/tests/Feature/IdentityPolicyResolverTest.php b/tests/Feature/IdentityPolicyResolverTest.php new file mode 100644 index 0000000..9d69fcb --- /dev/null +++ b/tests/Feature/IdentityPolicyResolverTest.php @@ -0,0 +1,122 @@ +assignments = app(AssignmentStore::class); + $this->roles = app(RoleStore::class); + $this->resolver = new IdentityPolicyResolver($this->assignments, $this->roles); +}); + +it('returns an empty collection when principal has no assignments', function (): void { + $request = new EvaluationRequest( + principal: new Principal('App\\Models\\User', 1), + action: 'posts.create', + ); + + $statements = $this->resolver->resolve($request); + + expect($statements)->toBeEmpty(); +}); + +it('produces Allow statements from role permissions', function (): void { + $this->roles->save('editor', 'Editor', ['posts.create', 'posts.read']); + $this->assignments->assign('App\\Models\\User', 1, 'editor'); + + $request = new EvaluationRequest( + principal: new Principal('App\\Models\\User', 1), + action: 'posts.create', + ); + + $statements = $this->resolver->resolve($request); + + expect($statements)->toHaveCount(2); + + $actions = $statements->pluck('action')->all(); + expect($actions)->toContain('posts.create') + ->and($actions)->toContain('posts.read'); + + $statements->each(function (PolicyStatement $stmt): void { + expect($stmt->effect)->toBe(Effect::Allow) + ->and($stmt->source)->toBe('role:editor'); + }); +}); + +it('produces Deny statements from !-prefixed permissions', function (): void { + $this->roles->save('restricted', 'Restricted', ['posts.read', '!posts.delete']); + $this->assignments->assign('App\\Models\\User', 1, 'restricted'); + + $request = new EvaluationRequest( + principal: new Principal('App\\Models\\User', 1), + action: 'posts.delete', + ); + + $statements = $this->resolver->resolve($request); + + $denyStatements = $statements->filter(fn (PolicyStatement $s) => $s->effect === Effect::Deny); + expect($denyStatements)->toHaveCount(1); + + $deny = $denyStatements->first(); + expect($deny->action)->toBe('posts.delete') + ->and($deny->source)->toBe('role:restricted'); + + $allowStatements = $statements->filter(fn (PolicyStatement $s) => $s->effect === Effect::Allow); + expect($allowStatements)->toHaveCount(1) + ->and($allowStatements->first()->action)->toBe('posts.read'); +}); + +it('includes scoped assignments when context has a scope', function (): void { + $this->roles->save('viewer', 'Viewer', ['posts.read']); + $this->assignments->assign('App\\Models\\User', 2, 'viewer', 'team::7'); + + $request = new EvaluationRequest( + principal: new Principal('App\\Models\\User', 2), + action: 'posts.read', + context: new Context(scope: 'team::7'), + ); + + $statements = $this->resolver->resolve($request); + + expect($statements)->toHaveCount(1) + ->and($statements->first()->action)->toBe('posts.read') + ->and($statements->first()->effect)->toBe(Effect::Allow) + ->and($statements->first()->source)->toBe('role:viewer'); +}); + +it('includes both global and scoped assignments when context has a scope', function (): void { + $this->roles->save('editor', 'Editor', ['posts.create']); + $this->roles->save('viewer', 'Viewer', ['posts.read']); + + $this->assignments->assign('App\\Models\\User', 3, 'editor'); + $this->assignments->assign('App\\Models\\User', 3, 'viewer', 'team::9'); + + $request = new EvaluationRequest( + principal: new Principal('App\\Models\\User', 3), + action: 'posts.create', + context: new Context(scope: 'team::9'), + ); + + $statements = $this->resolver->resolve($request); + + expect($statements)->toHaveCount(2); + + $sources = $statements->pluck('source')->all(); + expect($sources)->toContain('role:editor') + ->and($sources)->toContain('role:viewer'); + + $actions = $statements->pluck('action')->all(); + expect($actions)->toContain('posts.create') + ->and($actions)->toContain('posts.read'); +}); diff --git a/tests/Feature/JsonDocumentParserTest.php b/tests/Feature/JsonDocumentParserTest.php index 56c606a..3c77241 100644 --- a/tests/Feature/JsonDocumentParserTest.php +++ b/tests/Feature/JsonDocumentParserTest.php @@ -9,9 +9,9 @@ $this->parser = app(DocumentParser::class); }); -// --- parse() --- +// --- parse() v1 format --- -it('parses a valid full document', function (): void { +it('parses a valid full v1 document', function (): void { $json = json_encode([ 'version' => '1.0', 'permissions' => ['posts.create', 'posts.delete'], @@ -46,7 +46,7 @@ ->toBe(['scope' => 'group::5', 'max_permissions' => ['posts.create']]); }); -it('parses a partial document with only roles', function (): void { +it('parses a partial v1 document with only roles', function (): void { $json = json_encode([ 'version' => '1.0', 'roles' => [ @@ -80,9 +80,109 @@ $this->parser->parse('"just a string"'); })->throws(InvalidArgumentException::class, 'Invalid JSON'); +// --- parse() v2 format --- + +it('parses a v2 document with roles keyed by id', function (): void { + $json = json_encode([ + 'version' => '2.0', + 'permissions' => ['posts.create', 'posts.read', 'posts.update'], + 'roles' => [ + 'editor' => [ + 'permissions' => ['posts.create', 'posts.read', 'posts.update'], + 'conditions' => [ + 'posts.update' => [ + ['type' => 'attribute_equals', 'parameters' => ['subject_key' => 'dept', 'resource_key' => 'dept']], + ], + ], + ], + ], + 'assignments' => [], + 'boundaries' => [ + 'free-tier' => ['max_permissions' => ['posts.read']], + ], + 'resource_policies' => [], + ]); + + $document = $this->parser->parse($json); + + expect($document->version)->toBe('2.0'); + expect($document->roles)->toHaveCount(1); + + // Parser normalizes v2 roles into v1-compatible array-of-objects + $editorRole = $document->roles[0]; + expect($editorRole['id'])->toBe('editor'); + expect($editorRole['permissions'])->toBe(['posts.create', 'posts.read', 'posts.update']); + + // Boundaries normalized to v1-compatible format + expect($document->boundaries)->toHaveCount(1); + expect($document->boundaries[0]['scope'])->toBe('free-tier'); + expect($document->boundaries[0]['max_permissions'])->toBe(['posts.read']); +}); + +it('parses v2 resource_policies array', function (): void { + $json = json_encode([ + 'version' => '2.0', + 'permissions' => [], + 'roles' => [], + 'assignments' => [], + 'boundaries' => [], + 'resource_policies' => [ + [ + 'resource_type' => 'post', + 'resource_id' => null, + 'effect' => 'Allow', + 'action' => 'posts.read', + 'principal_pattern' => '*', + 'conditions' => [], + ], + ], + ]); + + $document = $this->parser->parse($json); + + expect($document->resourcePolicies)->toHaveCount(1); + expect($document->resourcePolicies[0]['resource_type'])->toBe('post'); + expect($document->resourcePolicies[0]['effect'])->toBe('Allow'); + expect($document->resourcePolicies[0]['action'])->toBe('posts.read'); + expect($document->resourcePolicies[0]['principal_pattern'])->toBe('*'); + expect($document->resourcePolicies[0]['resource_id'])->toBeNull(); +}); + +it('parses v2 system role flag', function (): void { + $json = json_encode([ + 'version' => '2.0', + 'permissions' => ['posts.read'], + 'roles' => [ + 'admin' => ['permissions' => ['posts.read'], 'system' => true], + 'viewer' => ['permissions' => ['posts.read']], + ], + 'assignments' => [], + 'boundaries' => [], + 'resource_policies' => [], + ]); + + $document = $this->parser->parse($json); + + expect($document->roles)->toHaveCount(2); + + $admin = collect($document->roles)->firstWhere('id', 'admin'); + expect($admin['system'])->toBeTrue(); + + $viewer = collect($document->roles)->firstWhere('id', 'viewer'); + expect($viewer)->not->toHaveKey('system'); +}); + +it('returns empty resourcePolicies when not present in document', function (): void { + $json = json_encode(['version' => '1.0']); + + $document = $this->parser->parse($json); + + expect($document->resourcePolicies)->toBe([]); +}); + // --- serialize() --- -it('serializes a document to pretty-printed JSON', function (): void { +it('serializes a document to v2 format with pretty-printed JSON', function (): void { $document = new PolicyDocument( version: '1.0', permissions: ['posts.create'], @@ -94,15 +194,71 @@ $json = $this->parser->serialize($document); $decoded = json_decode($json, associative: true); - expect($decoded)->toBe([ - 'version' => '1.0', - 'permissions' => ['posts.create'], - 'roles' => [ - ['id' => 'admin', 'name' => 'Admin', 'permissions' => ['posts.create']], + // Always outputs version 2.0 + expect($decoded['version'])->toBe('2.0'); + expect($decoded['permissions'])->toBe(['posts.create']); + + // Roles converted to v2 keyed format + expect($decoded['roles'])->toBeArray(); + expect($decoded['roles'])->toHaveKey('admin'); + expect($decoded['roles']['admin']['permissions'])->toBe(['posts.create']); + + expect($decoded['assignments'])->toBe([]); + expect($decoded['boundaries'])->toBe([]); + expect($decoded['resource_policies'])->toBe([]); +}); + +it('serializes v2 roles correctly when already in keyed format', function (): void { + $document = new PolicyDocument( + version: '2.0', + permissions: ['posts.read'], + roles: [ + 'editor' => ['permissions' => ['posts.read']], ], - 'assignments' => [], - 'boundaries' => [], - ]); + ); + + $json = $this->parser->serialize($document); + $decoded = json_decode($json, associative: true); + + expect($decoded['roles'])->toHaveKey('editor'); + expect($decoded['roles']['editor']['permissions'])->toBe(['posts.read']); +}); + +it('serializes boundaries in v2 keyed format', function (): void { + $document = new PolicyDocument( + version: '1.0', + boundaries: [ + ['scope' => 'team::5', 'max_permissions' => ['posts.read']], + ], + ); + + $json = $this->parser->serialize($document); + $decoded = json_decode($json, associative: true); + + expect($decoded['boundaries'])->toHaveKey('team::5'); + expect($decoded['boundaries']['team::5']['max_permissions'])->toBe(['posts.read']); +}); + +it('includes resource_policies in serialized output', function (): void { + $document = new PolicyDocument( + version: '2.0', + resourcePolicies: [ + [ + 'resource_type' => 'post', + 'resource_id' => null, + 'effect' => 'Allow', + 'action' => 'posts.read', + 'principal_pattern' => '*', + 'conditions' => [], + ], + ], + ); + + $json = $this->parser->serialize($document); + $decoded = json_decode($json, associative: true); + + expect($decoded['resource_policies'])->toHaveCount(1); + expect($decoded['resource_policies'][0]['action'])->toBe('posts.read'); }); it('produces JSON with unescaped slashes', function (): void { @@ -116,35 +272,45 @@ expect($json)->not->toContain('api\\/posts.create'); }); -it('round-trips a document through serialize and parse', function (): void { +it('round-trips a v2 document through serialize and parse', function (): void { $original = new PolicyDocument( version: '2.0', permissions: ['posts.create', 'posts.delete', '!posts.publish'], roles: [ - ['id' => 'editor', 'name' => 'Editor', 'permissions' => ['posts.create', 'posts.delete']], - ['id' => 'viewer', 'name' => 'Viewer', 'permissions' => ['posts.read'], 'system' => true], + 'editor' => ['permissions' => ['posts.create', 'posts.delete']], + 'viewer' => ['permissions' => ['posts.read'], 'system' => true], ], assignments: [ ['subject' => 'user::1', 'role' => 'editor'], ['subject' => 'user::2', 'role' => 'viewer', 'scope' => 'group::5'], ], boundaries: [ - ['scope' => 'group::5', 'max_permissions' => ['posts.create', 'posts.read']], + 'group::5' => ['max_permissions' => ['posts.create', 'posts.read']], ], + resourcePolicies: [], ); $restored = $this->parser->parse($this->parser->serialize($original)); - expect($restored->version)->toBe($original->version); + expect($restored->version)->toBe('2.0'); expect($restored->permissions)->toBe($original->permissions); - expect($restored->roles)->toBe($original->roles); expect($restored->assignments)->toBe($original->assignments); - expect($restored->boundaries)->toBe($original->boundaries); + expect($restored->resourcePolicies)->toBe([]); + + // Roles: serialized to v2 keyed and parsed back to v1 indexed format + expect($restored->roles)->toHaveCount(2); + $editorRole = collect($restored->roles)->firstWhere('id', 'editor'); + expect($editorRole['permissions'])->toBe(['posts.create', 'posts.delete']); + + // Boundaries: serialized to v2 keyed and parsed back to v1 indexed format + expect($restored->boundaries)->toHaveCount(1); + expect($restored->boundaries[0]['scope'])->toBe('group::5'); + expect($restored->boundaries[0]['max_permissions'])->toBe(['posts.create', 'posts.read']); }); // --- validate() --- -it('validates a valid full document', function (): void { +it('validates a valid full v1 document', function (): void { $json = json_encode([ 'version' => '1.0', 'permissions' => ['posts.create'], @@ -166,6 +332,30 @@ ->errors->toBe([]); }); +it('validates a valid v2 document', function (): void { + $json = json_encode([ + 'version' => '2.0', + 'permissions' => ['posts.create', 'posts.read'], + 'roles' => [ + 'editor' => ['permissions' => ['posts.create', 'posts.read']], + ], + 'assignments' => [ + ['subject' => 'user::1', 'role' => 'editor'], + ], + 'boundaries' => [ + 'free-tier' => ['max_permissions' => ['posts.read']], + ], + 'resource_policies' => [ + ['resource_type' => 'post', 'effect' => 'Allow', 'action' => 'posts.read'], + ], + ]); + + $result = $this->parser->validate($json); + + expect($result->valid)->toBeTrue(); + expect($result->errors)->toBe([]); +}); + it('validates a minimal document with only version', function (): void { $json = json_encode(['version' => '1.0']); @@ -209,7 +399,7 @@ expect($result->errors)->toContain('permissions[2] must be a string'); }); -it('rejects roles missing required keys', function (): void { +it('rejects v1 roles missing required keys', function (): void { $json = json_encode([ 'version' => '1.0', 'roles' => [ @@ -224,6 +414,20 @@ expect($result->errors)->toContain('roles[0] is missing required key: permissions'); }); +it('rejects v2 roles missing permissions key', function (): void { + $json = json_encode([ + 'version' => '2.0', + 'roles' => [ + 'admin' => ['name' => 'Admin'], + ], + ]); + + $result = $this->parser->validate($json); + + expect($result)->valid->toBeFalse(); + expect($result->errors)->toContain('roles[admin] is missing required key: permissions'); +}); + it('rejects assignments missing required keys', function (): void { $json = json_encode([ 'version' => '1.0', @@ -239,7 +443,7 @@ expect($result->errors)->toContain('assignments[0] is missing required key: role'); }); -it('rejects boundaries missing required keys', function (): void { +it('rejects v1 boundaries missing required keys', function (): void { $json = json_encode([ 'version' => '1.0', 'boundaries' => [ @@ -253,6 +457,36 @@ expect($result->errors)->toContain('boundaries[0] is missing required key: max_permissions'); }); +it('rejects v2 boundaries missing max_permissions key', function (): void { + $json = json_encode([ + 'version' => '2.0', + 'boundaries' => [ + 'free-tier' => [], + ], + ]); + + $result = $this->parser->validate($json); + + expect($result)->valid->toBeFalse(); + expect($result->errors)->toContain('boundaries[free-tier] is missing required key: max_permissions'); +}); + +it('rejects resource_policies missing required keys', function (): void { + $json = json_encode([ + 'version' => '2.0', + 'resource_policies' => [ + ['principal_pattern' => '*'], + ], + ]); + + $result = $this->parser->validate($json); + + expect($result)->valid->toBeFalse(); + expect($result->errors)->toContain('resource_policies[0] is missing required key: resource_type'); + expect($result->errors)->toContain('resource_policies[0] is missing required key: effect'); + expect($result->errors)->toContain('resource_policies[0] is missing required key: action'); +}); + it('rejects roles that is not an array', function (): void { $json = json_encode([ 'version' => '1.0', @@ -267,7 +501,7 @@ expect($result->errors)->toContain('roles must be an array'); }); -it('rejects a non-array role entry', function (): void { +it('rejects a non-array v1 role entry', function (): void { $json = json_encode([ 'version' => '1.0', 'roles' => ['not-an-object', 42], @@ -321,7 +555,7 @@ expect($result->errors)->toContain('boundaries must be an array'); }); -it('rejects a non-array boundary entry', function (): void { +it('rejects a non-array v1 boundary entry', function (): void { $json = json_encode([ 'version' => '1.0', 'boundaries' => ['not-an-object', 99], @@ -334,7 +568,7 @@ expect($result->errors)->toContain('boundaries[1] must be an object'); }); -it('rejects role permissions that is a string instead of an array', function (): void { +it('rejects v1 role permissions that is a string instead of an array', function (): void { $json = json_encode([ 'version' => '1.0', 'roles' => [ @@ -348,7 +582,7 @@ expect($result->errors)->toContain('roles[0].permissions must be an array'); }); -it('rejects role permissions containing non-string items', function (): void { +it('rejects v1 role permissions containing non-string items', function (): void { $json = json_encode([ 'version' => '1.0', 'roles' => [ @@ -363,7 +597,22 @@ expect($result->errors)->toContain('roles[0].permissions[1] must be a string'); }); -it('rejects boundary max_permissions that is a string instead of an array', function (): void { +it('rejects v2 role permissions containing non-string items', function (): void { + $json = json_encode([ + 'version' => '2.0', + 'roles' => [ + 'editor' => ['permissions' => [123, null]], + ], + ]); + + $result = $this->parser->validate($json); + + expect($result)->valid->toBeFalse(); + expect($result->errors)->toContain('roles[editor].permissions[0] must be a string'); + expect($result->errors)->toContain('roles[editor].permissions[1] must be a string'); +}); + +it('rejects v1 boundary max_permissions that is a string instead of an array', function (): void { $json = json_encode([ 'version' => '1.0', 'boundaries' => [ @@ -377,7 +626,7 @@ expect($result->errors)->toContain('boundaries[0].max_permissions must be an array'); }); -it('rejects boundary max_permissions containing non-string items', function (): void { +it('rejects v1 boundary max_permissions containing non-string items', function (): void { $json = json_encode([ 'version' => '1.0', 'boundaries' => [ @@ -391,7 +640,7 @@ expect($result->errors)->toContain('boundaries[0].max_permissions[0] must be a string'); }); -it('rejects non-string role id', function (): void { +it('rejects non-string v1 role id', function (): void { $json = json_encode([ 'version' => '1.0', 'roles' => [ @@ -405,7 +654,7 @@ expect($result->errors)->toContain('roles[0].id must be a string'); }); -it('rejects non-string role name', function (): void { +it('rejects non-string v1 role name', function (): void { $json = json_encode([ 'version' => '1.0', 'roles' => [ @@ -447,7 +696,7 @@ expect($result->errors)->toContain('assignments[0].role must be a string'); }); -it('rejects non-string boundary scope', function (): void { +it('rejects non-string v1 boundary scope', function (): void { $json = json_encode([ 'version' => '1.0', 'boundaries' => [ diff --git a/tests/Feature/MiddlewareTest.php b/tests/Feature/MiddlewareTest.php index ad15f3c..4b0bddc 100644 --- a/tests/Feature/MiddlewareTest.php +++ b/tests/Feature/MiddlewareTest.php @@ -281,7 +281,7 @@ class MiddlewareTestTeam extends Model // --- can middleware: Sanctum token scoping --- it('can middleware denies when Sanctum token lacks the required ability', function (): void { - Route::middleware('can:posts.create')->get('/sanctum-test', fn () => response()->json(['ok' => true])); + Route::middleware('can:posts.create')->get('/sanctum-deny-test', fn () => response()->json(['ok' => true])); $this->permissionStore->register(['posts.create', 'posts.read']); $this->roleStore->save('editor', 'Editor', ['posts.create', 'posts.read']); @@ -294,7 +294,7 @@ class MiddlewareTestTeam extends Model $sanctumUser->withAccessToken($token); $this->actingAs($sanctumUser) - ->getJson('/sanctum-test') + ->getJson('/sanctum-deny-test') ->assertForbidden(); }); diff --git a/tests/Feature/PolicyEngineFacadeTest.php b/tests/Feature/PolicyEngineFacadeTest.php index efc7417..4757946 100644 --- a/tests/Feature/PolicyEngineFacadeTest.php +++ b/tests/Feature/PolicyEngineFacadeTest.php @@ -147,9 +147,9 @@ $decoded = json_decode($output, true); expect($decoded)->toBeArray() - ->and($decoded['version'])->toBe('1.0') + ->and($decoded['version'])->toBe('2.0') ->and($decoded['permissions'])->toBe(['posts.create', 'posts.read']) - ->and($decoded['roles'][0]['id'])->toBe('editor'); + ->and($decoded['roles'])->toHaveKey('editor'); }); it('exports the current configuration to a file', function (): void { @@ -163,9 +163,9 @@ $decoded = json_decode(file_get_contents($path), true); expect($decoded)->toBeArray() - ->and($decoded['version'])->toBe('1.0') + ->and($decoded['version'])->toBe('2.0') ->and($decoded['permissions'])->toBe(['posts.create', 'posts.read']) - ->and($decoded['roles'][0]['id'])->toBe('editor'); + ->and($decoded['roles'])->toHaveKey('editor'); unlink($path); }); diff --git a/tests/Feature/ResourcePolicyResolverTest.php b/tests/Feature/ResourcePolicyResolverTest.php new file mode 100644 index 0000000..35aaaf5 --- /dev/null +++ b/tests/Feature/ResourcePolicyResolverTest.php @@ -0,0 +1,120 @@ +store = app(ResourcePolicyStore::class); + $this->resolver = new ResourcePolicyResolver($this->store); +}); + +it('returns an empty collection when the request has no resource', function (): void { + $request = new EvaluationRequest( + principal: new Principal(type: 'App\\Models\\User', id: 1), + action: 'documents.read', + resource: null, + context: new Context, + ); + + $statements = $this->resolver->resolve($request); + + expect($statements)->toBeEmpty(); +}); + +it('returns policies attached to the requested resource', function (): void { + $statement = new PolicyStatement( + effect: Effect::Allow, + action: 'documents.read', + principalPattern: null, + resourcePattern: null, + conditions: [], + source: '', + ); + + $this->store->attach('App\\Models\\Document', 5, $statement); + + $request = new EvaluationRequest( + principal: new Principal(type: 'App\\Models\\User', id: 1), + action: 'documents.read', + resource: new Resource(type: 'App\\Models\\Document', id: 5), + context: new Context, + ); + + $statements = $this->resolver->resolve($request); + + expect($statements)->toHaveCount(1) + ->and($statements->first()->action)->toBe('documents.read') + ->and($statements->first()->effect)->toBe(Effect::Allow); +}); + +it('returns type-level policies when querying a specific resource instance', function (): void { + $typeLevelStatement = new PolicyStatement( + effect: Effect::Deny, + action: 'documents.delete', + principalPattern: null, + resourcePattern: null, + conditions: [], + source: '', + ); + + $instanceStatement = new PolicyStatement( + effect: Effect::Allow, + action: 'documents.read', + principalPattern: null, + resourcePattern: null, + conditions: [], + source: '', + ); + + $this->store->attach('App\\Models\\Document', null, $typeLevelStatement); + $this->store->attach('App\\Models\\Document', 9, $instanceStatement); + + $request = new EvaluationRequest( + principal: new Principal(type: 'App\\Models\\User', id: 1), + action: 'documents.read', + resource: new Resource(type: 'App\\Models\\Document', id: 9), + context: new Context, + ); + + $statements = $this->resolver->resolve($request); + + expect($statements)->toHaveCount(2); + + $actions = $statements->pluck('action')->sort()->values()->all(); + expect($actions)->toBe(['documents.delete', 'documents.read']); +}); + +it('returns no policies when resource type does not match', function (): void { + $statement = new PolicyStatement( + effect: Effect::Allow, + action: 'documents.read', + principalPattern: null, + resourcePattern: null, + conditions: [], + source: '', + ); + + $this->store->attach('App\\Models\\Document', 1, $statement); + + $request = new EvaluationRequest( + principal: new Principal(type: 'App\\Models\\User', id: 1), + action: 'documents.read', + resource: new Resource(type: 'App\\Models\\Invoice', id: 1), + context: new Context, + ); + + $statements = $this->resolver->resolve($request); + + expect($statements)->toBeEmpty(); +}); diff --git a/tests/Feature/SanctumScopingTest.php b/tests/Feature/SanctumScopingTest.php index b68c595..e64fef6 100644 --- a/tests/Feature/SanctumScopingTest.php +++ b/tests/Feature/SanctumScopingTest.php @@ -7,7 +7,11 @@ use DynamikDev\PolicyEngine\Contracts\Evaluator; use DynamikDev\PolicyEngine\Contracts\PermissionStore; use DynamikDev\PolicyEngine\Contracts\RoleStore; -use DynamikDev\PolicyEngine\Enums\EvaluationResult; +use DynamikDev\PolicyEngine\DTOs\Context; +use DynamikDev\PolicyEngine\DTOs\EvaluationRequest; +use DynamikDev\PolicyEngine\DTOs\Principal; +use DynamikDev\PolicyEngine\Enums\Decision; +use DynamikDev\PolicyEngine\Resolvers\SanctumPolicyResolver; use Illuminate\Database\Schema\Blueprint; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -54,44 +58,33 @@ class SanctumTestUser extends Authenticatable it('allows when Sanctum token has a matching ability', function (): void { $token = new PersonalAccessToken; - $token->abilities = ['posts.create', 'posts.read']; - + $token->abilities = ['posts.create']; $this->user->withAccessToken($token); - $this->actingAs($this->user); + auth()->login($this->user); - expect($this->evaluator->can( - $this->user->getMorphClass(), - $this->user->getKey(), - 'posts.create', - ))->toBeTrue(); + expect($this->user->canDo('posts.create'))->toBeTrue(); }); it('allows when Sanctum token has wildcard ability', function (): void { $token = new PersonalAccessToken; $token->abilities = ['*']; - $this->user->withAccessToken($token); - $this->actingAs($this->user); + auth()->login($this->user); - expect($this->evaluator->can( - $this->user->getMorphClass(), - $this->user->getKey(), - 'posts.delete', - ))->toBeTrue(); + expect($this->user->canDo('posts.create'))->toBeTrue(); + expect($this->user->canDo('posts.read'))->toBeTrue(); + expect($this->user->canDo('posts.delete'))->toBeTrue(); }); it('allows when Sanctum token ability matches via wildcard pattern', function (): void { $token = new PersonalAccessToken; $token->abilities = ['posts.*']; - $this->user->withAccessToken($token); - $this->actingAs($this->user); + auth()->login($this->user); - expect($this->evaluator->can( - $this->user->getMorphClass(), - $this->user->getKey(), - 'posts.create', - ))->toBeTrue(); + expect($this->user->canDo('posts.create'))->toBeTrue(); + expect($this->user->canDo('posts.read'))->toBeTrue(); + expect($this->user->canDo('posts.delete'))->toBeTrue(); }); // --- Sanctum token without matching ability denies --- @@ -99,102 +92,83 @@ class SanctumTestUser extends Authenticatable it('denies when Sanctum token does not include the required ability', function (): void { $token = new PersonalAccessToken; $token->abilities = ['posts.read']; - $this->user->withAccessToken($token); - $this->actingAs($this->user); + auth()->login($this->user); - expect($this->evaluator->can( - $this->user->getMorphClass(), - $this->user->getKey(), - 'posts.delete', - ))->toBeFalse(); + expect($this->user->canDo('posts.create'))->toBeFalse(); + expect($this->user->canDo('posts.read'))->toBeTrue(); }); it('denies when Sanctum token has empty abilities', function (): void { $token = new PersonalAccessToken; $token->abilities = []; - $this->user->withAccessToken($token); - $this->actingAs($this->user); + auth()->login($this->user); - expect($this->evaluator->can( - $this->user->getMorphClass(), - $this->user->getKey(), - 'posts.create', - ))->toBeFalse(); + expect($this->user->canDo('posts.create'))->toBeFalse(); + expect($this->user->canDo('posts.read'))->toBeFalse(); + expect($this->user->canDo('posts.delete'))->toBeFalse(); }); // --- No Sanctum token (session auth) allows normally --- it('allows normally when no Sanctum token is present (session auth)', function (): void { - $this->actingAs($this->user); + // No token set — currentAccessToken() returns a transient token (not PersonalAccessToken). + auth()->login($this->user); - expect($this->evaluator->can( - $this->user->getMorphClass(), - $this->user->getKey(), - 'posts.create', - ))->toBeTrue(); + expect($this->user->canDo('posts.create'))->toBeTrue(); + expect($this->user->canDo('posts.read'))->toBeTrue(); }); it('allows normally when user is not authenticated', function (): void { - expect($this->evaluator->can( - $this->user->getMorphClass(), - $this->user->getKey(), - 'posts.create', - ))->toBeTrue(); + /* When there is no authenticated user, the SanctumPolicyResolver returns empty — verify it injects no Deny statements. */ + $resolver = app(SanctumPolicyResolver::class); + + $request = new EvaluationRequest( + principal: new Principal( + type: $this->user->getMorphClass(), + id: $this->user->getKey(), + ), + action: 'posts.create', + context: new Context, + ); + + $statements = $resolver->resolve($request); + + expect($statements)->toBeEmpty(); }); // --- explain() mirrors Sanctum token scoping --- it('explain reports deny with sanctum note when token lacks required ability', function (): void { - config()->set('policy-engine.explain', true); - $token = new PersonalAccessToken; $token->abilities = ['posts.read']; - $this->user->withAccessToken($token); - $this->actingAs($this->user); + auth()->login($this->user); - $trace = $this->evaluator->explain( - $this->user->getMorphClass(), - $this->user->getKey(), - 'posts.delete', - ); + $result = $this->user->explain('posts.create'); - expect($trace->result)->toBe(EvaluationResult::Deny) - ->and($trace->sanctum)->toBe('Denied by Sanctum token ability restriction'); + expect($result->decision)->toBe(Decision::Deny); + expect($result->decidedBy)->toBe('sanctum-token'); }); it('explain reports allow with no sanctum note when token includes required ability', function (): void { - config()->set('policy-engine.explain', true); - $token = new PersonalAccessToken; - $token->abilities = ['posts.create', 'posts.read']; - + $token->abilities = ['posts.create']; $this->user->withAccessToken($token); - $this->actingAs($this->user); + auth()->login($this->user); - $trace = $this->evaluator->explain( - $this->user->getMorphClass(), - $this->user->getKey(), - 'posts.create', - ); + $result = $this->user->explain('posts.create'); - expect($trace->result)->toBe(EvaluationResult::Allow) - ->and($trace->sanctum)->toBeNull(); + expect($result->decision)->toBe(Decision::Allow); + expect($result->decidedBy)->not->toBe('sanctum-token'); }); it('explain reports allow with no sanctum note when no token is present', function (): void { - config()->set('policy-engine.explain', true); + auth()->login($this->user); - $this->actingAs($this->user); - - $trace = $this->evaluator->explain( - $this->user->getMorphClass(), - $this->user->getKey(), - 'posts.create', - ); + $result = $this->user->explain('posts.create'); - expect($trace->result)->toBe(EvaluationResult::Allow) - ->and($trace->sanctum)->toBeNull(); + expect($result->decision)->toBe(Decision::Allow); + expect($result->decidedBy)->not->toBe('sanctum-token'); }); diff --git a/tests/Feature/SecurityTest.php b/tests/Feature/SecurityTest.php index a6348cd..515f9a3 100644 --- a/tests/Feature/SecurityTest.php +++ b/tests/Feature/SecurityTest.php @@ -8,8 +8,12 @@ use DynamikDev\PolicyEngine\Contracts\Evaluator; use DynamikDev\PolicyEngine\Contracts\PermissionStore; use DynamikDev\PolicyEngine\Contracts\RoleStore; +use DynamikDev\PolicyEngine\DTOs\Context; +use DynamikDev\PolicyEngine\DTOs\EvaluationRequest; use DynamikDev\PolicyEngine\DTOs\ImportOptions; use DynamikDev\PolicyEngine\DTOs\PolicyDocument; +use DynamikDev\PolicyEngine\DTOs\Principal; +use DynamikDev\PolicyEngine\Enums\Decision; use DynamikDev\PolicyEngine\PolicyEngineManager; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -187,6 +191,21 @@ unlink($path); }); +/** + * Helper: evaluate a permission for a subject using the new v2 Evaluator contract. + */ +function securityEval(Evaluator $evaluator, string $type, string|int $id, string $action, ?string $scope = null): bool +{ + $result = $evaluator->evaluate(new EvaluationRequest( + principal: new Principal(type: $type, id: $id), + action: $action, + resource: null, + context: new Context(scope: $scope), + )); + + return $result->decision === Decision::Allow; +} + // --- Finding 6: deny_unbounded_scopes --- it('denies scoped permission when no boundary exists and deny_unbounded_scopes is enabled', function (): void { @@ -198,7 +217,7 @@ $evaluator = app(Evaluator::class); - expect($evaluator->can('App\\Models\\User', 1, 'posts.create:company::99'))->toBeFalse(); + expect(securityEval($evaluator, 'App\\Models\\User', 1, 'posts.create', 'company::99'))->toBeFalse(); }); it('allows scoped permission when no boundary exists and deny_unbounded_scopes is disabled', function (): void { @@ -210,7 +229,7 @@ $evaluator = app(Evaluator::class); - expect($evaluator->can('App\\Models\\User', 1, 'posts.create:company::99'))->toBeTrue(); + expect(securityEval($evaluator, 'App\\Models\\User', 1, 'posts.create', 'company::99'))->toBeTrue(); }); // --- Finding 8: Wildcard deny across roles in scoped context --- @@ -224,8 +243,8 @@ $evaluator = app(Evaluator::class); - expect($evaluator->can('App\\Models\\User', 1, 'billing.refund:org::1'))->toBeFalse() - ->and($evaluator->can('App\\Models\\User', 1, 'billing.view:org::1'))->toBeTrue(); + expect(securityEval($evaluator, 'App\\Models\\User', 1, 'billing.refund', 'org::1'))->toBeFalse() + ->and(securityEval($evaluator, 'App\\Models\\User', 1, 'billing.view', 'org::1'))->toBeTrue(); }); // --- Finding 9: Full wildcard grant with wildcard deny --- @@ -239,22 +258,25 @@ $evaluator = app(Evaluator::class); - expect($evaluator->can('App\\Models\\User', 1, 'posts.create'))->toBeFalse() - ->and($evaluator->can('App\\Models\\User', 1, 'billing.manage'))->toBeTrue(); + expect(securityEval($evaluator, 'App\\Models\\User', 1, 'posts.create'))->toBeFalse() + ->and(securityEval($evaluator, 'App\\Models\\User', 1, 'billing.manage'))->toBeTrue(); }); // --- Finding 10: Boundary enforcement on global assignment checking scoped permission --- it('enforces boundary on scoped check even when user has global wildcard assignment', function (): void { $this->permissionStore->register(['posts.create', 'billing.manage']); - $this->roleStore->save('admin', 'Admin', ['*.*']); - $this->assignmentStore->assign('App\\Models\\User', 1, 'admin'); - $this->boundaryStore->set('org::acme', ['posts.*']); + $this->roleStore->save('superadmin', 'Super Admin', ['*.*']); + $this->assignmentStore->assign('App\\Models\\User', 1, 'superadmin'); + $this->boundaryStore->set('org::restricted', ['posts.*']); $evaluator = app(Evaluator::class); - expect($evaluator->can('App\\Models\\User', 1, 'billing.manage:org::acme'))->toBeFalse() - ->and($evaluator->can('App\\Models\\User', 1, 'posts.create:org::acme'))->toBeTrue(); + // billing.manage is outside the boundary ceiling for org::restricted. + expect(securityEval($evaluator, 'App\\Models\\User', 1, 'billing.manage', 'org::restricted'))->toBeFalse(); + + // posts.create is within the boundary ceiling. + expect(securityEval($evaluator, 'App\\Models\\User', 1, 'posts.create', 'org::restricted'))->toBeTrue(); }); // --- Finding 11: PathValidator rejects paths with non-existent parent directories ---