| name | waaseyaa:access-control |
| description | Use when working with access policies, field access, authorization middleware, gates, route access control, AccessResult semantics, permission handlers, or files in packages/access/, packages/routing/src/AccessChecker.php, packages/user/src/Middleware/. Covers entity-level access (deny-by-default via isAllowed), field-level access (open-by-default via !isForbidden), the Gate system, route options (_public, _permission, _role, _gate), and the SessionMiddleware/AuthorizationMiddleware pipeline in public/index.php. |
Access Control Specialist
Scope
This skill covers the Waaseyaa access control system:
- Entity-level access:
AccessPolicyInterface, EntityAccessHandler, AccessResult
- Field-level access:
FieldAccessPolicyInterface, checkFieldAccess(), filterFields()
- Route-level access:
AccessChecker, route options, AuthorizationMiddleware
- Gate system:
Gate, GateInterface, PolicyAttribute, AccessDeniedException
- Session resolution:
SessionMiddleware, AccountInterface
- Permission registry:
PermissionHandler, PermissionHandlerInterface
- API integration:
ResourceSerializer field filtering, SchemaPresenter access annotations
- Discovery:
#[AccessPolicy] attribute, manifest compilation
Key Interfaces
AccessPolicyInterface
File: packages/access/src/AccessPolicyInterface.php
interface AccessPolicyInterface
{
public function access(EntityInterface $entity, string $operation, AccountInterface $account): AccessResult;
public function createAccess(string $entityTypeId, string $bundle, AccountInterface $account): AccessResult;
public function appliesTo(string $entityTypeId): bool;
}
Operations for access(): 'view', 'update', 'delete'.
FieldAccessPolicyInterface
File: packages/access/src/FieldAccessPolicyInterface.php
interface FieldAccessPolicyInterface
{
public function fieldAccess(
EntityInterface $entity,
string $fieldName,
string $operation, // 'view' or 'edit'
AccountInterface $account,
): AccessResult;
}
Must be implemented alongside AccessPolicyInterface. EntityAccessHandler finds field policies via instanceof FieldAccessPolicyInterface.
AccountInterface
File: packages/access/src/AccountInterface.php
interface AccountInterface
{
public function id(): int|string;
public function hasPermission(string $permission): bool;
public function getRoles(): array;
public function isAuthenticated(): bool;
}
AccessResult
File: packages/access/src/AccessResult.php
Three states: AccessResult::allowed(), AccessResult::neutral(), AccessResult::forbidden().
Combination operators:
orIf(): Forbidden wins, either Allowed yields Allowed. Used by EntityAccessHandler.
andIf(): Forbidden wins, both must be Allowed. Used by AccessChecker for route requirements.
EntityAccessHandler
File: packages/access/src/EntityAccessHandler.php
class EntityAccessHandler
{
public function __construct(array $policies = []);
public function addPolicy(AccessPolicyInterface $policy): void;
public function check(EntityInterface $entity, string $operation, AccountInterface $account): AccessResult;
public function checkCreateAccess(string $entityTypeId, string $bundle, AccountInterface $account): AccessResult;
public function checkFieldAccess(EntityInterface $entity, string $fieldName, string $operation, AccountInterface $account): AccessResult;
public function filterFields(EntityInterface $entity, array $fieldNames, string $operation, AccountInterface $account): array;
}
GateInterface
File: packages/access/src/Gate/GateInterface.php
interface GateInterface
{
public function allows(string $ability, mixed $subject, ?object $user = null): bool;
public function denies(string $ability, mixed $subject, ?object $user = null): bool;
public function authorize(string $ability, mixed $subject, ?object $user = null): void;
}
AccessChecker
File: packages/routing/src/AccessChecker.php
final class AccessChecker
{
public function __construct(private readonly ?GateInterface $gate = null);
public function check(Route $route, AccountInterface $account): AccessResult;
public static function applyGateToRoute(Route $route, string $ability, mixed $subject = null): void;
}
Route options: _public (bool), _permission (string), _role (string, comma-separated), _gate (array).
Architecture
Authorization Pipeline
Request -> SessionMiddleware -> AuthorizationMiddleware -> Final Handler -> Response
SessionMiddleware (packages/user/src/Middleware/SessionMiddleware.php): reads $_SESSION['waaseyaa_uid'], loads User entity, falls back to AnonymousUser, sets _account on request attributes.
AuthorizationMiddleware (packages/access/src/Middleware/AuthorizationMiddleware.php): reads _account and _route_object from request, delegates to AccessChecker::check(), returns 403 JSON:API response on Forbidden.
Asymmetric Access Semantics
This is the most critical architectural detail:
| Level | Check Method | Default Stance | Interpretation |
|---|
| Entity | $result->isAllowed() | Deny unless granted | Neutral = denied |
| Field | !$result->isForbidden() | Allow unless denied | Neutral = accessible |
Entity access: deny-by-default. A policy must return Allowed.
Field access: open-by-default. Only explicit Forbidden restricts.
Package Dependencies
access (layer 1) -- owns AccountInterface, AccessPolicyInterface, FieldAccessPolicyInterface, EntityAccessHandler
|
+-- Does NOT depend on user package
|
user (layer 1) -- owns User, AnonymousUser, SessionMiddleware
|
+-- Depends on access (for AccountInterface)
|
routing (layer 4) -- owns AccessChecker
|
+-- Depends on access (for AccessResult, AccountInterface, GateInterface)
AccountInterface lives in access, not user. This prevents circular dependencies. Always type-hint AccountInterface, not AnonymousUser.
Paired Nullable Parameters
ResourceSerializer::serialize() and SchemaPresenter::present() accept ?EntityAccessHandler + ?AccountInterface. Both must be non-null or both null.
if ($handler !== null && $account !== null) {
}
x-access-restricted
JSON Schema extension marking fields viewable but not editable. The admin SPA reads this to show disabled widgets. Distinct from system readOnly (id, uuid) which hides the field entirely.
{
"status": {
"type": "boolean",
"readOnly": true,
"x-access-restricted": true
}
}
Common Mistakes
Wrong access check method for the level
if ($handler->checkFieldAccess($entity, 'title', 'view', $account)->isAllowed()) { ... }
if (!$handler->checkFieldAccess($entity, 'title', 'view', $account)->isForbidden()) { ... }
Circular dependency: access depending on user
use Waaseyaa\User\AnonymousUser;
use Waaseyaa\Access\AccountInterface;
Trying to mock final classes or intersection types
$policy = $this->createMock(AccessPolicyInterface::class);
$policy = $this->createMock(AccessPolicyInterface::class & FieldAccessPolicyInterface::class);
$policy = new class implements AccessPolicyInterface, FieldAccessPolicyInterface {
public function access(EntityInterface $entity, string $operation, AccountInterface $account): AccessResult {
return AccessResult::allowed();
}
public function createAccess(string $entityTypeId, string $bundle, AccountInterface $account): AccessResult {
return AccessResult::neutral();
}
public function appliesTo(string $entityTypeId): bool {
return true;
}
public function fieldAccess(EntityInterface $entity, string $fieldName, string $operation, AccountInterface $account): AccessResult {
return AccessResult::neutral();
}
};
Forgetting that only FieldAccessPolicyInterface implementations participate
EntityAccessHandler skips policies that do not implement FieldAccessPolicyInterface during field access checks. An AccessPolicyInterface-only class will never have fieldAccess() called.
Double entity creation in access checks
When checking field access before persisting a new entity, create the entity once and reuse it for both the access check and the save. Do not create a throwaway temp entity for the access check.
Missing paired nullable parameters
Passing EntityAccessHandler without AccountInterface (or vice versa) results in no filtering. Both must be provided.
Layer discipline for discovery
Foundation (layer 0) must never import from access (layer 1). Policy attribute scanning in PackageManifestCompiler uses string constants:
private const POLICY_ATTRIBUTE = 'Waaseyaa\\Access\\Gate\\PolicyAttribute';
Gate naming convention mismatch
Gate resolves NodePolicy to entity type node via PascalCase-to-snake_case conversion. Use str_replace('_', '', ucwords($name, '_')) for the reverse (snake_case to PascalCase), not ucfirst().
Testing Patterns
Unit Tests
Use real instances, not mocks, for EntityAccessHandler (not a final class but policies often are):
$handler = new EntityAccessHandler([$policy1, $policy2]);
$result = $handler->check($entity, 'view', $account);
self::assertTrue($result->isAllowed());
Anonymous Classes for Policies
$policy = new class implements AccessPolicyInterface, FieldAccessPolicyInterface {
};
In-Memory Infrastructure
$db = PdoDatabase::createSqlite();
$storage = new InMemoryEntityStorage();
Integration Test Location
tests/Integration/Phase6/FieldAccessIntegrationTest.php
tests/Integration/Phase11/AuthorizationPipelineTest.php
PHPUnit Attributes
#[Test]
#[CoversClass(EntityAccessHandler::class)]
public function checkFieldAccessReturnsForbiddenForRestrictedField(): void
Use #[CoversNothing] for integration tests.
Related Specs
docs/specs/access-control.md -- Entity-level access, route access, Gate system, authorization pipeline
docs/specs/field-access.md -- Field-level access, asymmetric semantics, x-access-restricted, testing patterns
Key Files
packages/access/src/AccessPolicyInterface.php
packages/access/src/FieldAccessPolicyInterface.php
packages/access/src/AccessResult.php
packages/access/src/AccessStatus.php
packages/access/src/EntityAccessHandler.php
packages/access/src/AccountInterface.php
packages/access/src/PermissionHandler.php
packages/access/src/PermissionHandlerInterface.php
packages/access/src/Attribute/AccessPolicy.php
packages/access/src/Gate/Gate.php
packages/access/src/Gate/GateInterface.php
packages/access/src/Gate/PolicyAttribute.php
packages/access/src/Gate/AccessDeniedException.php
packages/access/src/Middleware/AuthorizationMiddleware.php
packages/routing/src/AccessChecker.php
packages/user/src/Middleware/SessionMiddleware.php
packages/api/src/ResourceSerializer.php
packages/api/src/JsonApiController.php
packages/api/src/Schema/SchemaPresenter.php
public/index.php