waffle-commons / security
Hierarchical Attribute-Based Access Control for Waffle Commons: fail-closed by default, object-level voters, a stateless HMAC CSRF subsystem bound to a per-browser session id, and fail-closed CORS.
Requires
- php: ^8.5
- psr/container: ^2.0
- psr/http-factory: ^1.0
- psr/http-server-middleware: ^1.0
- psr/log: ^3.0
- waffle-commons/contracts: 0.1.0-beta7
- waffle-commons/utils: 0.1.0-beta7
Requires (Dev)
- carthage-software/mago: ^1.59
- cyclonedx/cyclonedx-php-composer: ^6.2
- igor-php/igor-php: ^0.10.1
- nyholm/psr7: ^1.8
- php-mock/php-mock-phpunit: ^2.16
- phpunit/phpunit: ^12.5
- vimeo/psalm: ^6.20
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-05 02:30:29 UTC
README
Waffle Security Component
Release:
0.1.0-beta7ย |ย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-7 โ rate limiting (NET-01)
Middleware\RateLimitMiddlewarekeys each call by the authenticated subject, else by the client IP resolved behind trusted proxies, and answers429withRetry-Afterwhen the bucket is empty. Enforced responses carryRateLimit-Limit,RateLimit-RemainingandRateLimit-Reset.RateLimit\RateLimiterimplementsRateLimiterInterface(a token bucket) over the sharedLimiterStateStorageInterface;waffle-commons/data'sRedisLimiterStateStoragekeeps the buckets in Redis, so every worker and every instance shares them.- A storage outage follows
on_storage_failure(RateLimit\StorageFailureMode):openby default, which lets the call through unenforced with a warning, orclosed, which answers503. Given aMetricsRegistryInterface,RateLimitercounts each outage inwaffle_ratelimit_storage_errors_total{mode}(RateLimiter::STORAGE_ERRORS_METRIC).
How-to: Rate-limit requests.
๐ 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). |
Waffle\Commons\Security\Middleware\RateLimitMiddleware |
PSR-15 rate limiting (Beta-7, NET-01): 429 + Retry-After on an empty bucket, RateLimit-* headers on enforced responses. |
Waffle\Commons\Security\RateLimit\RateLimiter |
RateLimiterInterface token bucket over the shared LimiterStateStorageInterface, with a StorageFailureMode (open / closed). |
๐ฆ 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; } }
๐ท๏ธ Levels are application-wide
The level is one setting for the whole application, waffle.security.level: there is no attribute that declares a level on a controller (there is no #[Rule] attribute). Security::analyze() walks the rule ladder up to the configured level and throws a SecurityExceptionInterface, which the ErrorHandlerMiddleware renders as an RFC 7807 403. Per-action authorization is the job of #[Voter], below.
๐ช 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*. #[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
๐ Documentation
Central framework docs live in the waffle-commons/documentation repository:
- Reference: security
- How-to: Rate-limit requests
- Explanation: Fail-closed ABAC
- Explanation: CSRF double-submit
- Explanation: Two-layer authorization
๐ License
MIT โ see LICENSE.md.