Skip to content
Back to skills

Access Control

ASecurity

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 ...

  • 4 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 12, 2026
testingphpsqlnodetestingapidatabase

Works with

  • api

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned September 12, 2026

npx -y skills add aibot88/sec_skill_store --skill access-control --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Access Control?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Access Control
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/aibot88-access-control/badge)](https://www.skillsdirectory.com/skills/aibot88-access-control)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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`

```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`

```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`

```php
interface AccountInterface
{
    public function id(): int|string;
    public function hasPermission(string $permission): bool;
    public function getRoles(): array; // string[]
    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`

```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`

```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`

```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.

```php
if ($handler !== null && $account !== null) {
    // Apply field filtering
}
```

### 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.

```json
{
  "status": {
    "type": "boolean",
    "readOnly": true,
    "x-access-restricted": true
  }
}
```

## Common Mistakes

### Wrong access check method for the level

```php
// WRONG: using isAllowed() for field check
if ($handler->checkFieldAccess($entity, 'title', 'view', $account)->isAllowed()) { ... }

// CORRECT: field access uses !isForbidden()
if (!$handler->checkFieldAccess($entity, 'title', 'view', $account)->isForbidden()) { ... }
```

### Circular dependency: access depending on user

```php
// WRONG: importing from user package into access package
use Waaseyaa\User\AnonymousUser;

// CORRECT: type-hint the interface from access package
use Waaseyaa\Access\AccountInterface;
```

### Trying to mock final classes or intersection types

```php
// WRONG: PHPUnit can't mock final class or intersection types
$policy = $this->createMock(AccessPolicyInterface::class); // may fail if final
$policy = $this->createMock(AccessPolicyInterface::class & FieldAccessPolicyInterface::class); // fails

// CORRECT: use anonymous 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:

```php
private const POLICY_ATTRIBUTE = 'Waaseyaa\\Access\\Gate\\PolicyAttribute';
// NOT: use 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):

```php
$handler = new EntityAccessHandler([$policy1, $policy2]);
$result = $handler->check($entity, 'view', $account);
self::assertTrue($result->isAllowed());
```

### Anonymous Classes for Policies

```php
$policy = new class implements AccessPolicyInterface, FieldAccessPolicyInterface {
    // ... implement all methods with inline logic
};
```

### In-Memory Infrastructure

```php
$db = PdoDatabase::createSqlite(); // :memory: SQLite
$storage = new InMemoryEntityStorage(); // from Waaseyaa\Api\Tests\Fixtures
```

### Integration Test Location

```
tests/Integration/Phase6/FieldAccessIntegrationTest.php
tests/Integration/Phase11/AuthorizationPipelineTest.php
```

### PHPUnit Attributes

```php
#[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
```

Files in this skill

  • SKILL.md11.6 KB
  • source.json1 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…