Search by

adachsoft / access-control-contracts

Arkadiusz Adach

Generic contracts for accounts, roles, permission grants and access decisions.

Package info

gitlab.com/a.adach/access-control-contracts

Issues

pkg:composer/adachsoft/access-control-contracts

Statistics

Installs: 16

Dependents: 2

Suggesters: 0

Stars: 0

v0.1.0 2026-11-13 07:39 UTC

This package is auto-updated.

Last update: 2026-11-13 07:06:31 UTC


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 as task.edit; * matches permissions generally, subject to the reserved namespace below.
  • Conditions: A grant condition is evaluated by the configured RuleEvaluatorInterface. The evaluator receives actor (id, type, and roles) and resource data. Associative arrays are converted recursively to objects, so expressions can use dot notation, for example resource.author == actor.id or '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 with access.. Grant sensitive access explicitly, for example with access.impersonate or access.*.
  • Impersonation: A caller must be granted access.impersonate to 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 an AccountRepositoryInterface from createRepository() with no arguments.
  • AbstractRoleDefinitionRepositoryContractTest: return a RoleDefinitionRepositoryInterface from createRepository(RoleDefinitionCollection $roleDefinitions, RoleAssignmentRuleCollection $assignmentRules).
  • AbstractRoleAssignmentRuleRepositoryContractTest: return a RoleAssignmentRuleRepositoryInterface from createRepository(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:

  • AccountNotFoundException
  • AccountAlreadyExistsException
  • ConcurrentModificationException
  • NoMatchingAssignmentRuleException
  • ImpersonationNotAllowedException
  • InvalidDefinitionException

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.