adachsoft / access-control-contracts
Generic contracts for accounts, roles, permission grants and access decisions.
Package info
gitlab.com/a.adach/access-control-contracts
pkg:composer/adachsoft/access-control-contracts
Requires
- php: ^8.3
- adachsoft/collection: ^3.0
- adachsoft/rule-evaluator-contract: ^0.1
- psr/clock: ^1.0
Requires (Dev)
- adachsoft/php-code-style: ^0.7.2
- adachsoft/rule-evaluator-expression-language: ^0.1
- friendsofphp/php-cs-fixer: ^3.95
- phpstan/phpstan: ^2.3
- phpunit/phpunit: ^13.4
- rector/rector: ^2.8
Suggests
- phpunit/phpunit: Required to run the repository contract tests in testing/.
Provides
None
Conflicts
None
Replaces
None
README
adachsoft/access-control-contracts is a framework-independent library for deciding whether an account may perform an action. It defines account and role contracts, permission grants, conditional decisions, just-in-time (JIT) account provisioning, and impersonation authorization.
Authentication is outside this library's scope. The host application authenticates a caller, maps that identity to an account ID, and passes an AccountId to this library. The library does not validate credentials, authenticate requests, or integrate with a specific application domain.
Requirements
- PHP 8.3 or later (PHP 8.x, as constrained by Composer).
- Composer.
- A rule-evaluator implementation when using conditional grants. The examples below use
adachsoft/rule-evaluator-expression-language.
Installation
Install the contracts package:
composer require adachsoft/access-control-contracts
To evaluate conditions with Symfony ExpressionLanguage and use the in-memory repositories shown below, also install the companion packages:
composer require adachsoft/rule-evaluator-expression-language
composer require adachsoft/access-control-memory
Applications can instead provide their own implementations of the repository interfaces and RuleEvaluatorInterface.
Concepts
- Account and JIT provisioning: An account can represent a human, an agent, or a service. When a service first needs an account, the first matching assignment rule supplies its type and initial roles. An account is not created if no assignment rule matches. Existing accounts can be managed through
AccountRepositoryInterface. - Assignment rules: Rules match account IDs using
*as a wildcard and are evaluated in declaration order; the first match wins. Put specific patterns before a catch-all rule. - Roles and grants: A role groups permission grants. A grant contains a permission pattern and may include a condition.
task.*matches one permission segment such astask.edit;*matches permissions generally, subject to the reserved namespace below. - Conditions: A grant condition is evaluated by the configured
RuleEvaluatorInterface. The evaluator receivesactor(id,type, androles) andresourcedata. Associative arrays are converted recursively to objects, so expressions can use dot notation, for exampleresource.author == actor.idor'admin' in actor.roles. - Default deny: Access is denied unless an active account has a role grant matching the requested permission and, when present, its condition evaluates to
true. An inactive account is always denied. - Reserved
access.namespace: The global permission pattern*does not match permissions beginning withaccess.. Grant sensitive access explicitly, for example withaccess.impersonateoraccess.*. - Impersonation: A caller must be granted
access.impersonateto request another account as the actor. The host remains responsible for authenticating the caller and supplying the caller and requested account IDs.
Definition file example
The following read-only JSON definition is the example from the access-control specification. The contracts package does not parse JSON; a definition repository or an implementation package is responsible for loading definitions.
{
"roles": [
{"id": "admin", "name": "Administrator", "grants": [{"permission": "*"}]},
{"id": "supervisor", "name": "Supervisor", "grants": [{"permission": "*"}, {"permission": "access.impersonate"}]},
{"id": "developer", "name": "Developer", "grants": [
{"permission": "task.create"},
{"permission": "task.edit", "condition": "resource.author == actor.id and resource.category == 'TODO'"},
{"permission": "task.change_status", "condition": "resource.author == actor.id"},
{"permission": "comment.*"}
]},
{"id": "agent", "name": "Agent", "grants": [{"permission": "comment.add"}]}
],
"assignment_rules": [
{"account": "aa", "type": "HUMAN", "roles": ["admin"]},
{"account": "claude", "type": "SERVICE", "roles": ["supervisor"]},
{"account": "dev_*", "type": "AGENT", "roles": ["developer"]},
{"account": "*", "type": "AGENT", "roles": ["agent"]}
]
}
Usage
With Composer's autoloader available, define roles and ordered assignment rules, then create the repositories and services. This executable example demonstrates a conditional decision, isGranted(), JIT provisioning, and resolveActor():
<?php
declare(strict_types=1);
use AdachSoft\AccessControlContracts\Collection\PermissionGrantCollection;
use AdachSoft\AccessControlContracts\Collection\RoleAssignmentRuleCollection;
use AdachSoft\AccessControlContracts\Collection\RoleDefinitionCollection;
use AdachSoft\AccessControlContracts\Collection\RoleIdCollection;
use AdachSoft\AccessControlContracts\Dto\PermissionGrantDto;
use AdachSoft\AccessControlContracts\Dto\RoleAssignmentRuleDto;
use AdachSoft\AccessControlContracts\Dto\RoleDefinitionDto;
use AdachSoft\AccessControlContracts\Enum\AccountTypeEnum;
use AdachSoft\AccessControlContracts\Factory\AccessControlFactory;
use AdachSoft\AccessControlContracts\ValueObject\AccountId;
use AdachSoft\AccessControlContracts\ValueObject\AccountPattern;
use AdachSoft\AccessControlContracts\ValueObject\PermissionPattern;
use AdachSoft\AccessControlContracts\ValueObject\RoleId;
use AdachSoft\AccessControlMemory\InMemoryAccountRepository;
use AdachSoft\AccessControlMemory\InMemoryRoleAssignmentRuleRepository;
use AdachSoft\AccessControlMemory\InMemoryRoleDefinitionRepository;
use AdachSoft\RuleEvaluatorExpressionLanguage\ExpressionLanguageRuleEvaluatorFactory;
$roleDefinitions = new RoleDefinitionCollection([
new RoleDefinitionDto(
new RoleId('developer'),
'Developer',
new PermissionGrantCollection([
new PermissionGrantDto(new PermissionPattern('task.create')),
new PermissionGrantDto(
new PermissionPattern('task.edit'),
"resource.author == actor.id and resource.category == 'TODO'",
),
new PermissionGrantDto(new PermissionPattern('comment.*')),
]),
),
new RoleDefinitionDto(
new RoleId('supervisor'),
'Supervisor',
new PermissionGrantCollection([
new PermissionGrantDto(new PermissionPattern('*')),
new PermissionGrantDto(new PermissionPattern('access.impersonate')),
]),
),
new RoleDefinitionDto(
new RoleId('agent'),
'Agent',
new PermissionGrantCollection([
new PermissionGrantDto(new PermissionPattern('comment.add')),
]),
),
]);
$assignmentRules = new RoleAssignmentRuleCollection([
new RoleAssignmentRuleDto(
new AccountPattern('claude'),
AccountTypeEnum::SERVICE,
new RoleIdCollection([new RoleId('supervisor')]),
),
new RoleAssignmentRuleDto(
new AccountPattern('dev_*'),
AccountTypeEnum::AGENT,
new RoleIdCollection([new RoleId('developer')]),
),
new RoleAssignmentRuleDto(
new AccountPattern('*'),
AccountTypeEnum::AGENT,
new RoleIdCollection([new RoleId('agent')]),
),
]);
$accountRepository = new InMemoryAccountRepository();
$roleDefinitionRepository = new InMemoryRoleDefinitionRepository($roleDefinitions, $assignmentRules);
$assignmentRuleRepository = new InMemoryRoleAssignmentRuleRepository($roleDefinitions, $assignmentRules);
$ruleEvaluator = (new ExpressionLanguageRuleEvaluatorFactory())->createDefault();
$accessDecisionService = AccessControlFactory::createAccessDecisionService(
$accountRepository,
$roleDefinitionRepository,
$assignmentRuleRepository,
$ruleEvaluator,
);
$decision = $accessDecisionService->decide(
new AccountId('dev_anna'),
'task.edit',
['author' => 'dev_anna', 'category' => 'TODO'],
);
$isGranted = $accessDecisionService->isGranted(
new AccountId('dev_anna'),
'task.edit',
['author' => 'someone-else', 'category' => 'TODO'],
);
$impersonationService = AccessControlFactory::createImpersonationService(
$accountRepository,
$roleDefinitionRepository,
$assignmentRuleRepository,
$ruleEvaluator,
);
$actor = $impersonationService->resolveActor(
new AccountId('claude'),
new AccountId('dev_anna'),
);
The first decision is granted, $isGranted is false because the resource belongs to another account, and $actor->value is dev_anna. Both accounts are provisioned on first use according to the ordered rules. tests/ReadmeExampleTest.php runs this same example against the current APIs.
Contract tests
The reusable PHPUnit repository contract suites are shipped in testing/ under the AdachSoft\AccessControlContracts\Testing\ namespace. Implementations can extend the applicable suite in their own test package and provide its createRepository() method:
AbstractAccountRepositoryContractTest: return anAccountRepositoryInterfacefromcreateRepository()with no arguments.AbstractRoleDefinitionRepositoryContractTest: return aRoleDefinitionRepositoryInterfacefromcreateRepository(RoleDefinitionCollection $roleDefinitions, RoleAssignmentRuleCollection $assignmentRules).AbstractRoleAssignmentRuleRepositoryContractTest: return aRoleAssignmentRuleRepositoryInterfacefromcreateRepository(RoleDefinitionCollection $roleDefinitions, RoleAssignmentRuleCollection $assignmentRules).
For example, an account repository test supplies its implementation as follows:
final class DatabaseAccountRepositoryContractTest extends AbstractAccountRepositoryContractTest
{
protected function createRepository(): AccountRepositoryInterface
{
return new DatabaseAccountRepository(/* connection or test fixture */);
}
}
Add PHPUnit as a development dependency in the implementation package. These suites check the shared repository behavior, including account versioning, role-definition consistency, and assignment-rule order, without requiring a separate contract-test repository.
In-memory implementation
The companion adachsoft/access-control-memory package in packages/access-control-memory/ provides InMemoryAccountRepository, InMemoryRoleDefinitionRepository, and InMemoryRoleAssignmentRuleRepository. It is useful for examples and tests. Its account state is process-local and is not durable storage; production applications should provide repositories backed by their persistence layer.
Exceptions
The contracts package defines these exceptions:
AccountNotFoundExceptionAccountAlreadyExistsExceptionConcurrentModificationExceptionNoMatchingAssignmentRuleExceptionImpersonationNotAllowedExceptionInvalidDefinitionException
An external rule evaluator may also report its own evaluation exceptions when a condition cannot be evaluated.
Development
Install the development dependencies and run the test suite and static analysis from the repository root:
composer install
vendor/bin/phpunit
vendor/bin/phpstan analyse
License
This project is licensed under the MIT License (SPDX identifier: MIT), as declared in composer.json.