Skip to content

Commit

Permalink
AccessEntry: matchAll() and matchAny()
Browse files Browse the repository at this point in the history
  • Loading branch information
mabar committed Feb 28, 2023
1 parent 41e3e72 commit 4d03bdb
Show file tree
Hide file tree
Showing 14 changed files with 662 additions and 29 deletions.
7 changes: 5 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,22 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
- `AccessEntry` - represents a single check in a policy
- `getType(): AccessEntryResult` - result of the check
- `getMessage(): string|Translatable` - text description of what was checked
- `matchAny(): MatchAnyOfEntries`, `matchAll(): MatchAllOfEntries` shortcuts to construct && and || conditions
- `AccessEntryResult`
- `allowed()`, `forbidden()`, `skipped()`
- `fromBool()` - shortcut for `allowed()` or `forbidden()`
- `MatchAllOfEntries` - for explicit && condition (implicit is default)
- `MatchAnyOfEntries` - for || condition
- `PolicyContext`
- `getLastExpiredLogin()`

### Changed

- `Policy`
- instead of returning `bool` uses `Generator` which yields 1 or more `AccessEntry`
- instead of returning `bool` uses `Generator` which yields 1 or more `AccessEntry|MatchAllOfEntries|MatchAnyOfEntries`
- `DecisionReason` removed from context (uses `AccessEntry` yielding instead)
- `Firewall`, `Authorizer`
- `isAllowed()` reason (`DecisionReason`) replaced by entries (`list<AccessEntry>`)
- `isAllowed()` reason (`DecisionReason`) replaced by entries (`list<AccessEntry|MatchAllOfEntries|MatchAnyOfEntries>`)
- `IdentityExpired`
- `create()` uses `string|TranslatableMessage` directly instead of `DecisionReason`
- `ExpiredLogin`
Expand Down
100 changes: 80 additions & 20 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Authentication and authorization
- [Policy with optional requirements](#policy-with-optional-requirements)
- [Policy with no requirements](#policy-with-no-requirements)
- [Policy with default-like privilege check](#policy-with-default-like-privilege-check)
- [Conditional entries](#conditional-entries)
- [Composed policy](#composed-policy)
- [Login aware policy](#login-aware-policy)
- [Root - bypass all checks](#root---bypass-all-checks)
Expand Down Expand Up @@ -808,16 +809,18 @@ $policyManager->add(new ArticleEditPolicy());
$policyManager->add(new ArticleEditOwnedPolicy());
```

Requirements can be made [optional](#policy-with-optional-requirements) or even [none](#policy-with-no-requirements) at
all.
Check following chapters to learn more about policies:

Policy is called only when user is logged-in. For logged-out
users, [make Identity optional](#policy-with-optional-log-in-check).

For privileges with registered policy, privilege itself **is not checked**. Policy has
to [do the check itself](#policy-with-default-like-privilege-check).

Policy is always skipped by [root](#root---bypass-all-checks).
- Requirements can be made [optional](#policy-with-optional-requirements) or even [none](#policy-with-no-requirements)
at all.
- Policy is called only when user is logged-in. For logged-out
users, [make Identity optional](#policy-with-optional-log-in-check).
- Privilege **is not checked**, when policy is used. Policy has
to [do the check itself](#policy-with-default-like-privilege-check).
- Create || and && conditions via [conditional entries](#conditional-entries)
- [Compose policies](#composed-policy) from other policies
- Check whether [user previously logged in](#login-aware-policy)
- Policy is always skipped by [root](#root---bypass-all-checks).

#### Policy with optional log-in check

Expand Down Expand Up @@ -989,6 +992,39 @@ final class DefaultCheckPolicy implements Policy
}
```

#### Conditional entries

Entries yielded by policy are combined with an && operator by default.

You can also combine them with || operator:

```php
use Orisai\Auth\Authorization\AccessEntry;
use Orisai\Auth\Authorization\AccessEntryResult;

yield AccessEntry::matchAny([
// first || second
new AccessEntry(AccessEntryResult::allowed(), /* ... */),
new AccessEntry(AccessEntryResult::forbidden(), /* ... */),
]);
```

Or explicitly use (the default) && operator:

```php
use Orisai\Auth\Authorization\AccessEntry;
use Orisai\Auth\Authorization\AccessEntryResult;

yield AccessEntry::matchAll([
// first && second
new AccessEntry(AccessEntryResult::allowed(), /* ... */),
new AccessEntry(AccessEntryResult::allowed(), /* ... */),
]);
```

Why not just regular || or && operator? With `matchAll()` and `matchAny()` you
can [show the required checks](#accessing-entries) to user.

#### Composed policy

Policies can call other policies internally and combine their results
Expand Down Expand Up @@ -1092,28 +1128,52 @@ $firewall->login($identity);

### Accessing entries

`AccessEntry` yielded by [policies](#policies---customized-authorization) are not used just to allow or forbid
policy-protected privilege. You can also use them to show user why exactly they were (not) given access.
`AccessEntry|MatchAllOfEntries|MatchAnyOfEntries` yielded by [policies](#policies---customized-authorization) are not
used just to allow or forbid policy-protected privilege. You can also use them to show user why exactly they were (not)
given access.

Entries are propagated to you via `isAllowed()` parameter `entries` reference:

```php
use Orisai\Auth\Authorization\MatchAllOfEntries;
use Orisai\Auth\Authorization\MatchAnyOfEntries;
use Orisai\TranslationContracts\Translatable;
use Orisai\TranslationContracts\Translator;

assert($translator instanceof Translator); // Create translator or get message id and parameters from Translatable

$firewall->isAllowed($privilege, $requirements, $entries); // $entries === list<AccessEntry>
$authorizer->isAllowed($identity, $privilege, $requirements, $entries); // $entries === list<AccessEntry>
$firewall->isAllowed($privilege, $requirements, $entries); // $entries === list<AccessEntry|MatchAllOfEntries|MatchAnyOfEntries>
$authorizer->isAllowed($identity, $privilege, $requirements, $entries); // $entries === list<AccessEntry|MatchAllOfEntries|MatchAnyOfEntries>

foreach ($entries as $entry) {
$result = $entry->getResult(); // AccessEntryResult
$message = $entry->getMessage(); // string|Translatable
if ($message instanceof Translatable) {
$message = $translator->translateMessage($message);
}
printEntries($entries);

function printEntries(array $entries): void
{
foreach ($entries as $entry) {
if ($entry instanceof MatchAllOfEntries) {
echo "\n";
echo "All of:\n";
printEntries($entry->getEntries());

continue;
}

echo "$result->value: $message"; // e.g. allowed: Author of the article
if ($entry instanceof MatchAnyOfEntries) {
echo "\n";
echo "Any of:\n";
printEntries($entry->getEntries());

continue;
}

$result = $entry->getResult(); // AccessEntryResult
$message = $entry->getMessage(); // string|Translatable
if ($message instanceof Translatable) {
$message = $translator->translateMessage($message);
}

echo "$result->value: $message\n"; // e.g. allowed: Author of the article
}
}
```

Expand Down
21 changes: 21 additions & 0 deletions src/Authorization/AccessEntry.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,25 @@ public function getMessage()
return $this->message;
}

/**
* @param list<AccessEntry|MatchAllOfEntries|MatchAnyOfEntries> $entries
*/
public static function matchAny(array $entries): MatchAnyOfEntries
{
return new MatchAnyOfEntries($entries);
}

/**
* @param list<AccessEntry|MatchAllOfEntries|MatchAnyOfEntries> $entries
*/
public static function matchAll(array $entries): MatchAllOfEntries
{
return new MatchAllOfEntries($entries);
}

public function match(): bool
{
return $this->result === AccessEntryResult::allowed();
}

}
4 changes: 2 additions & 2 deletions src/Authorization/Authorizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ interface Authorizer
public function hasPrivilege(Identity $identity, string $privilege): bool;

/**
* @param array{}|null $entries
* @param array{}|null $entries
* @param literal-string $privilege
* @param-out list<AccessEntry> $entries
* @param-out list<AccessEntry|MatchAllOfEntries|MatchAnyOfEntries> $entries
*/
public function isAllowed(
?Identity $identity,
Expand Down
46 changes: 46 additions & 0 deletions src/Authorization/MatchAllOfEntries.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php declare(strict_types = 1);

namespace Orisai\Auth\Authorization;

use Orisai\Exceptions\Logic\InvalidArgument;
use function count;

final class MatchAllOfEntries
{

/** @var list<AccessEntry|MatchAllOfEntries|MatchAnyOfEntries> */
private array $entries;

/**
* @param list<AccessEntry|MatchAllOfEntries|MatchAnyOfEntries> $entries
*/
public function __construct(array $entries)
{
if (count($entries) < 2) {
throw InvalidArgument::create()
->withMessage('At least 2 entries are required.');
}

$this->entries = $entries;
}

/**
* @return list<AccessEntry|MatchAllOfEntries|MatchAnyOfEntries>
*/
public function getEntries(): array
{
return $this->entries;
}

public function match(): bool
{
foreach ($this->entries as $entry) {
if (!$entry->match()) {
return false;
}
}

return true;
}

}
46 changes: 46 additions & 0 deletions src/Authorization/MatchAnyOfEntries.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php declare(strict_types = 1);

namespace Orisai\Auth\Authorization;

use Orisai\Exceptions\Logic\InvalidArgument;
use function count;

final class MatchAnyOfEntries
{

/** @var list<AccessEntry|MatchAllOfEntries|MatchAnyOfEntries> */
private array $entries;

/**
* @param list<AccessEntry|MatchAllOfEntries|MatchAnyOfEntries> $entries
*/
public function __construct(array $entries)
{
if (count($entries) < 2) {
throw InvalidArgument::create()
->withMessage('At least 2 entries are required.');
}

$this->entries = $entries;
}

/**
* @return list<AccessEntry|MatchAllOfEntries|MatchAnyOfEntries>
*/
public function getEntries(): array
{
return $this->entries;
}

public function match(): bool
{
foreach ($this->entries as $entry) {
if ($entry->match()) {
return true;
}
}

return false;
}

}
2 changes: 1 addition & 1 deletion src/Authorization/Policy.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public static function getRequirementsClass(): string;

/**
* @param R $requirements
* @return Generator<int, AccessEntry, null, void>
* @return Generator<int, AccessEntry|MatchAllOfEntries|MatchAnyOfEntries, null, void>
*/
public function isAllowed(Identity $identity, object $requirements, PolicyContext $context): Generator;

Expand Down
9 changes: 5 additions & 4 deletions src/Authorization/PrivilegeAuthorizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,9 @@ public function isAllowed(
}

/**
* @param array{}|null $entries
* @param array{}|null $entries
* @param literal-string $privilege
* @param-out list<AccessEntry> $entries
* @param-out list<AccessEntry|MatchAllOfEntries|MatchAnyOfEntries> $entries
*/
private function isAllowedInternal(
string $function,
Expand Down Expand Up @@ -200,8 +200,9 @@ private function isAllowedByPrivilege(
}

/**
* @param array{}|null $entries
* @param array{}|null $entries
* @param Policy<object> $policy
* @param-out list<AccessEntry|MatchAllOfEntries|MatchAnyOfEntries> $entries
*/
private function isAllowedByPolicy(
?Identity $identity,
Expand Down Expand Up @@ -263,7 +264,7 @@ private function isAllowedByPolicy(
$entries[] = $entry;

// If any entry is not allowed, policy forbids access
if ($isAllowed && $entry->getResult() !== AccessEntryResult::allowed()) {
if ($isAllowed && !$entry->match()) {
$isAllowed = false;
}
}
Expand Down
23 changes: 23 additions & 0 deletions tests/Doubles/EntriesFromContext.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php declare(strict_types = 1);

namespace Tests\Orisai\Auth\Doubles;

use Orisai\Auth\Authorization\AccessEntry;
use Orisai\Auth\Authorization\MatchAllOfEntries;
use Orisai\Auth\Authorization\MatchAnyOfEntries;

final class EntriesFromContext
{

/** @var list<AccessEntry|MatchAllOfEntries|MatchAnyOfEntries> */
public array $entries;

/**
* @param list<AccessEntry|MatchAllOfEntries|MatchAnyOfEntries> $entries
*/
public function __construct(array $entries)
{
$this->entries = $entries;
}

}
34 changes: 34 additions & 0 deletions tests/Doubles/EntriesFromContextPolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php declare(strict_types = 1);

namespace Tests\Orisai\Auth\Doubles;

use Generator;
use Orisai\Auth\Authentication\Identity;
use Orisai\Auth\Authorization\Policy;
use Orisai\Auth\Authorization\PolicyContext;

/**
* @implements Policy<EntriesFromContext>
*/
final class EntriesFromContextPolicy implements Policy
{

public static function getPrivilege(): string
{
return 'entries-from-context';
}

public static function getRequirementsClass(): string
{
return EntriesFromContext::class;
}

/**
* @param EntriesFromContext $requirements
*/
public function isAllowed(Identity $identity, object $requirements, PolicyContext $context): Generator
{
yield from $requirements->entries;
}

}

0 comments on commit 4d03bdb

Please sign in to comment.