waffle-commons / security
Security component for Waffle framework.
Requires
- php: ^8.5
- psr/container: ^2.0
- psr/http-server-middleware: ^1.0
- psr/log: ^3.0
- waffle-commons/contracts: 0.1.0-beta2.1
- waffle-commons/utils: 0.1.0-beta2.1
Requires (Dev)
- carthage-software/mago: ^1.29
- cyclonedx/cyclonedx-php-composer: ^6.2
- php-mock/php-mock-phpunit: ^2.15
- phpunit/phpunit: ^12.5
- vimeo/psalm: ^6.16
This package is auto-updated.
Last update: 2026-05-30 19:46:52 UTC
README
Waffle Security Component
Release:
v0.1.0-beta2ย |ยCHANGELOG.md
Hierarchical Attribute-Based Access Control (ABAC) for the Waffle Framework with a fail-closed default (SEC-02), a fully stateless HMAC CSRF subsystem bound to a per-browser anonymous SID (SEC-01 option C), and a container decorator (SecureContainer) that hardens service retrieval. Security is enforced by PSR-15 middleware sitting between routing and dispatch.
Beta-2 status
No behavioural changes since Beta-1 โ lockstep version bump only. The security architecture remains as described below.
๐ Beta-1 foundations (still current)
- Fail-closed ABAC โ
SecureContainer::analyze()rejects any action without a#[Voter]unless explicitly tagged#[PublicAccess]. Missing policy is denial, not silent allow. - Stateless HMAC CSRF โ
CsrfTokenManagerissues self-validating signed tokens; no cache, no Redis, no PHP sessions. The HMAC binds to(id, sessionId)so a token cannot be replayed across forms or across browsers. AnonymousSessionMiddlewareโ issues theWAFFLE_SIDcookie (32 random bytes, base64url, 30-day Max-Age, HttpOnly, SameSite=Lax, Secure on HTTPS) that anchors CSRF binding. Stateless across requests (FrankenPHP-safe).
๐ฆ Installation
composer require waffle-commons/security
๐งฑ Surface
| Class | Role |
|---|---|
Waffle\Commons\Security\Security |
SecurityInterface implementation. Reads waffle.security.level from ConfigInterface at construction (defaults to 1). |
Waffle\Commons\Security\Abstract\AbstractSecurity |
Shared base storing the configured security level and providing the analyze() walk. |
Waffle\Commons\Security\Middleware\SecurityMiddleware |
PSR-15 middleware that runs SecureContainer::analyze($controller, $method) โ fail-closed ABAC. |
Waffle\Commons\Security\Middleware\AnonymousSessionMiddleware |
PSR-15 middleware that issues / reuses the WAFFLE_SID cookie and publishes the SID as the _anon_sid request attribute. Required upstream of CsrfMiddleware. |
Waffle\Commons\Security\Middleware\CsrfMiddleware |
PSR-15 middleware enforcing #[RequiresCsrfToken]. Validates the signed token against (id, sessionId). |
Waffle\Commons\Security\Csrf\CsrfTokenManager |
final readonly stateless HMAC-SHA256 token manager. Constructor takes a 32+ byte secret. |
Waffle\Commons\Security\Container\SecureContainer |
Decorator over Waffle\Commons\Contracts\Container\ContainerInterface. Beta-1: analyze() is now fail-closed โ empty voter list โ SecurityException(403) unless #[PublicAccess] is present. |
Waffle\Commons\Security\Rule\Level1Rule โฆ Level10Rule |
The ten built-in security levels (1 = public โฆ 10 = god-mode). |
๐ฆ The security ladder
Each LevelNRule lives in src/Rule/. Levels are integer-coded via Waffle\Commons\Contracts\Constant\Constant::SECURITY_LEVEL1 โฆ SECURITY_LEVEL10. The kernel reads waffle.security.level from the application's app.yaml and constructs Security with that level.
# config/app.yaml waffle: security: level: 5 # Authenticated user with elevated permissions
use Waffle\Commons\Security\Security; use Waffle\Commons\Contracts\Config\ConfigInterface; $security = new Security($config); // reads waffle.security.level $security->analyze($controller); // throws SecurityExceptionInterface if rules fail
The exact constructor, verbatim from src/Security.php:
final class Security extends AbstractSecurity { public function __construct(ConfigInterface $cfg) { $this->level = $cfg->getInt(key: 'waffle.security.level', default: 1) ?? 1; } }
๐ท๏ธ #[Rule] โ declaring required levels
The attribute lives in the contracts package (Waffle\Commons\Contracts\Security\Attribute\Rule). Apply it to controller methods or classes:
use Waffle\Commons\Contracts\Security\Attribute\Rule; use Waffle\Commons\Contracts\Constant\Constant; final class AdminController { #[Rule(level: Constant::SECURITY_LEVEL10)] public function dangerous(): Response { /* โฆ */ } }
If Security::analyze() is invoked against a controller method that requires a level higher than the kernel's configured level, a SecurityExceptionInterface is thrown and the ErrorHandlerMiddleware renders it as RFC 7807 403.
๐ช Fail-closed ABAC + #[PublicAccess] (Beta-1 / SEC-02)
A controller action without any #[Voter] is now denied with HTTP 403 unless it explicitly carries #[PublicAccess]. Forgetting to attach a voter no longer silently grants access โ missing policy is treated as denial.
use Waffle\Commons\Contracts\Security\Attribute\PublicAccess; use Waffle\Commons\Routing\Attribute\Route; final class HealthController { #[Route(path: '/health', name: 'health')] #[PublicAccess] public function ping(): Response { /* โฆ */ } }
A method-level #[Voter] always wins over a class-level #[PublicAccess], so mixed-policy controllers stay safe.
๐ CSRF โ stateless signed double-submit with per-browser binding (Beta-1 / SEC-01)
CsrfMiddleware enforces #[RequiresCsrfToken] using HMAC-signed self-validating tokens. No cache, no Redis, no PHP sessions. Wire format (binary, then base64url):
nonce (16 bytes) || expiresAt (8 bytes BE uint64) || HMAC-SHA256(nonce || expiresAt || id || sessionId, secret)
Two pieces of context are folded into the HMAC:
- the logical id (e.g.
form:login) โ prevents cross-form replay; - the anonymous session id (the
WAFFLE_SIDcookie value, published as the_anon_sidrequest attribute byAnonymousSessionMiddleware) โ prevents cross-browser replay.
Operational requirements:
- Provide a 32+ byte signing secret. Production refuses to boot without one. Config key
waffle.security.csrf.secret, with env fallbackWAFFLE_CSRF_SECRET. - Wire
AnonymousSessionMiddlewarebeforeCsrfMiddlewarein the pipeline. The skeleton'sAppKernelFactorydoes this for you.
# config/app.yaml waffle: security: level: 5 csrf: secret: '%env(WAFFLE_CSRF_SECRET)%'
$csrfTokenManager = new CsrfTokenManager(secret: $csrfSecret); $container->set(CsrfTokenManagerInterface::class, $csrfTokenManager); $stack ->add(new AnonymousSessionMiddleware()) ->add(new CoreRoutingMiddleware($router)) ->add(new CsrfMiddleware($csrfTokenManager)) ->add(new SecurityMiddleware($secureContainer, $logger));
๐ก๏ธ SecureContainer
Waffle\Commons\Security\Container\SecureContainer wraps any ContainerInterface and runs the security check before get($id) returns the service โ preventing low-privilege code paths from pulling sensitive services out of the container.
analyze($controller, $method) is fail-closed as of Beta-1: an empty #[Voter] list throws SecurityException(403) unless the target carries #[PublicAccess]. Otherwise every voter must approve (consensus pattern) for the call to proceed.
๐ PHP 8.5 features used
- Typed constructors throughout (
SecuritytakesConfigInterface, level resolution is?int ?? 1). - Typed integer security levels declared as typed constants in
Constant::SECURITY_LEVEL*. #[Rule]/#[Voter]/#[RequiresCsrfToken]/#[PublicAccess]attributes from the contracts package.final readonly class CsrfTokenvalue object;final readonly class CsrfTokenManager(no instance state across requests).#[\SensitiveParameter]on the CSRF signing secret to suppress its value from stack traces and error reports.
๐งญ Architectural boundary (mago guard)
An active dependency perimeter is enforced on every CI run by vendor/bin/mago guard (bundled into composer mago; zero baselines). The rules live in mago.toml under [guard.perimeter] โ a forbidden use statement fails the build, not a reviewer.
Production code under Waffle\Commons\Security may depend only on:
Waffle\Commons\Security\**โ itselfWaffle\Commons\Contracts\**โ the shared contracts package (#[PublicAccess],#[Voter],RequiresCsrfToken, the CSRF constants, etc.)Waffle\Commons\Utils\**โ theClassParserreflection helper used bySecureContainerPsr\**โ PSR interfaces (PSR-7 / PSR-15)@global+Psl\**โ PHP core and the PHP Standard Library
Test code under WaffleTests\Commons\Security is unrestricted (@all). Structural rules are guarded too: interfaces must be named *Interface, Exception\** classes must end in *Exception, and any Enum\** namespace may hold only enum declarations.
Contract-first, component-agnostic by construction: components compose through waffle-commons/contracts (plus the explicitly-permitted utils), never ad-hoc through one another.
๐งช Testing
docker exec -w /waffle-commons/security waffle-dev composer tests
๐ License
MIT โ see LICENSE.md.