agent foundation

This commit is contained in:
2026-03-06 00:44:52 +01:00
parent 9819cba733
commit 9a08f96c11
199 changed files with 8522 additions and 1880 deletions

View File

@@ -9,6 +9,8 @@ use MintyPHP\Service\Auth\ApiTokenService;
use MintyPHP\Service\Auth\AuthScopeGateway;
use MintyPHP\Service\User\UserTenantContextService;
// Static facade for API authentication — state is set once per request during ApiBootstrap::init().
// Dependencies are injected as lazy resolvers so services are only instantiated when actually needed.
class ApiAuth
{
/** @var (callable(): AuthScopeGateway)|null */
@@ -58,6 +60,7 @@ class ApiAuth
{
$header = trim((string) ($_SERVER['HTTP_AUTHORIZATION'] ?? ''));
if ($header === '') {
// Nginx with PHP-FPM may expose the header under this key instead
$header = trim((string) ($_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''));
}
if ($header === '' || stripos($header, 'Bearer ') !== 0) {
@@ -100,9 +103,11 @@ class ApiAuth
$roleIds = $userRoleRepository->listRoleIdsByUserId($userId);
$permissions = $rolePermissionRepository->listPermissionKeysByRoleIds($roleIds);
// Resolve tenant context
// Resolve tenant context: a token may be scoped to a specific tenant,
// otherwise fall back to the user's active tenant context.
$tokenTenantId = $result['tenant_id'];
if ($tokenTenantId !== null) {
// Verify the token's tenant is still assigned to this user
$userTenantIds = $scopeGateway->getUserTenantIds($userId);
if (!in_array($tokenTenantId, $userTenantIds, true)) {
return false;
@@ -153,11 +158,14 @@ class ApiAuth
return $decision->isAllowed();
}
// Effective tenant for data filtering — may come from token scope or user session.
public static function tenantId(): ?int
{
return self::$currentTenantId;
}
// Non-null only when the token itself was issued for a specific tenant.
// Used to restrict access in token-scoped requests (e.g. tenant integrations).
public static function scopedTenantId(): ?int
{
return self::$tokenTenantId;
@@ -204,6 +212,7 @@ class ApiAuth
$scopeGateway = self::scopeGateway();
if (!$scopeGateway->resourceBelongsToTenant($resource, $resourceId, $tenantId)) {
// 404 instead of 403 — don't reveal that the resource exists in another tenant
ApiResponse::notFound();
}
}

View File

@@ -4,10 +4,13 @@ namespace MintyPHP\Http;
use MintyPHP\Service\Audit\ApiAuditService;
use MintyPHP\Service\Security\RateLimiterService;
use MintyPHP\Service\Settings\SettingGateway;
use MintyPHP\Service\Settings\SettingsApiPolicyGateway;
// Entry point bootstrapper called once at the top of every API action.
// Handles: request context, CORS, audit start, rate limiting, and optional Bearer auth.
class ApiBootstrap
{
// Two separate rate-limit buckets: one per IP (broad), one per token+IP (fine-grained).
private const API_RATE_SCOPE_TOKEN = 'api.token_ip';
private const API_RATE_SCOPE_IP = 'api.ip';
private const API_RATE_LIMIT_TOKEN = 300;
@@ -19,8 +22,8 @@ class ApiBootstrap
private static bool $shutdownRegistered = false;
/** @var (callable(): ApiAuditService)|null */
private static $apiAuditServiceResolver = null;
/** @var (callable(): SettingGateway)|null */
private static $settingGatewayResolver = null;
/** @var (callable(): SettingsApiPolicyGateway)|null */
private static $settingsApiPolicyGatewayResolver = null;
/** @var (callable(): RateLimiterService)|null */
private static $rateLimiterServiceResolver = null;
/** @var (callable(): ApiSystemAuditReporter)|null */
@@ -28,12 +31,12 @@ class ApiBootstrap
public static function configure(
callable $apiAuditServiceResolver,
callable $settingGatewayResolver,
callable $settingsApiPolicyGatewayResolver,
callable $rateLimiterServiceResolver,
callable $apiSystemAuditReporterResolver
): void {
self::$apiAuditServiceResolver = $apiAuditServiceResolver;
self::$settingGatewayResolver = $settingGatewayResolver;
self::$settingsApiPolicyGatewayResolver = $settingsApiPolicyGatewayResolver;
self::$rateLimiterServiceResolver = $rateLimiterServiceResolver;
self::$apiSystemAuditReporterResolver = $apiSystemAuditReporterResolver;
}
@@ -93,7 +96,7 @@ class ApiBootstrap
return;
}
$allowedOrigins = array_fill_keys(self::settings()->getApiCorsAllowedOrigins(), true);
$allowedOrigins = array_fill_keys(self::settingsApiPolicy()->getApiCorsAllowedOrigins(), true);
if (!isset($allowedOrigins[$origin])) {
return;
}
@@ -105,6 +108,7 @@ class ApiBootstrap
header('Access-Control-Max-Age: 86400');
}
// Guarantees audit records are written even on uncaught fatal errors.
private static function registerShutdownHandler(): void
{
if (self::$shutdownRegistered) {
@@ -216,6 +220,8 @@ class ApiBootstrap
}
}
// API tokens are formatted as "selector:verifier". The selector (24-char hex) is used
// as the rate-limit key so a single token can't bypass the per-IP bucket by rotating IPs.
private static function extractBearerSelector(): string
{
$token = ApiAuth::extractBearerToken();
@@ -232,9 +238,13 @@ class ApiBootstrap
return $selector;
}
private static function settings(): SettingGateway
private static function settingsApiPolicy(): SettingsApiPolicyGateway
{
return self::resolveDependency(self::$settingGatewayResolver, SettingGateway::class, 'ApiBootstrap');
return self::resolveDependency(
self::$settingsApiPolicyGatewayResolver,
SettingsApiPolicyGateway::class,
'ApiBootstrap'
);
}
private static function rateLimiter(): RateLimiterService

View File

@@ -225,6 +225,7 @@ class ApiResponse
];
}
// Empty details must be stdClass so json_encode produces {} instead of [].
private static function normalizeDetails(array $details): array|\stdClass
{
if ($details === []) {

View File

@@ -91,6 +91,8 @@ class ApiSystemAuditReporter
);
}
// Recording strategy: always log writes and server errors; for reads, only log
// failures on security-sensitive endpoints (auth, tokens) to keep volume manageable.
private function shouldRecord(string $method, string $path, int $statusCode): bool
{
if ($method === 'OPTIONS') {
@@ -123,6 +125,8 @@ class ApiSystemAuditReporter
return str_starts_with($path, '/api/v1/me/tokens');
}
// Normalizes dynamic path segments (IDs, UUIDs, token selectors) so audit queries
// can group by endpoint pattern rather than individual resource IDs.
private function endpointKey(string $path): string
{
$path = '/' . ltrim(trim($path), '/');

34
lib/Http/CookieStore.php Normal file
View File

@@ -0,0 +1,34 @@
<?php
namespace MintyPHP\Http;
class CookieStore implements CookieStoreInterface
{
public function get(string $name): string
{
return (string) ($_COOKIE[$name] ?? '');
}
/**
* @param array<string, mixed> $options
*/
public function set(string $name, string $value, array $options = []): void
{
setcookie($name, $value, $options);
$_COOKIE[$name] = $value;
}
/**
* @param array<string, mixed> $options
*/
public function remove(string $name, array $options = []): void
{
$removeOptions = ['expires' => time() - 3600, 'path' => '/'];
foreach ($options as $key => $value) {
$removeOptions[$key] = $value;
}
setcookie($name, '', $removeOptions);
unset($_COOKIE[$name]);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace MintyPHP\Http;
interface CookieStoreInterface
{
public function get(string $name): string;
/**
* @param array<string, mixed> $options
*/
public function set(string $name, string $value, array $options = []): void;
/**
* @param array<string, mixed> $options
*/
public function remove(string $name, array $options = []): void;
}

View File

@@ -17,6 +17,7 @@ final class RequestInputFactory
$query = $_GET;
$files = $_FILES;
// API requests carry a JSON body; web requests use $_POST form data.
if (defined('MINTY_API_REQUEST')) {
return new RequestInput($method, $query, $this->readApiBody(), $files);
}

View File

@@ -30,6 +30,8 @@ class Request
return self::stripBasePath($path);
}
// Returns a safe redirect target after login — guards against open-redirect attacks
// by rejecting any value with a scheme or host (i.e. an absolute external URL).
public static function safeReturnTarget(string $returnParam = ''): string
{
if ($returnParam !== '') {

View File

@@ -4,6 +4,8 @@ namespace MintyPHP\Http;
use MintyPHP\Repository\Support\RepoQuery;
// Holds per-request metadata (id, channel, method, path, ip) as a static singleton.
// Must be initialized once at the entry point (web, api, cli) via start().
final class RequestContext
{
/** @var array<string, mixed>|null */
@@ -27,6 +29,7 @@ final class RequestContext
self::$context = self::buildDefaultContext();
}
// Returns the request ID, generating one if missing. Use this when you need a guaranteed value.
public static function id(): string
{
self::ensureStarted();
@@ -38,6 +41,7 @@ final class RequestContext
return $requestId;
}
// Returns the ID only if already set — safe to call before start() (e.g. in error handlers).
public static function currentId(): ?string
{
if (self::$context === null) {
@@ -80,6 +84,7 @@ final class RequestContext
return self::id();
}
// HMAC when a key is configured (e.g. hashing IPs for audit logs) — plain SHA256 as fallback.
public static function hashValue(string $value): string
{
$secret = defined('APP_CRYPTO_KEY') ? trim((string) APP_CRYPTO_KEY) : '';
@@ -191,6 +196,8 @@ final class RequestContext
return in_array($channel, ['web', 'api', 'scheduler', 'cli'], true) ? $channel : '';
}
// Accept a caller-supplied request ID for cross-service trace correlation.
// Only trusted if it's a valid UUID format — arbitrary strings are ignored.
private static function requestIdFromHeader(): ?string
{
$header = trim((string) ($_SERVER['HTTP_X_REQUEST_ID'] ?? ''));

View File

@@ -0,0 +1,66 @@
<?php
namespace MintyPHP\Http;
class RequestRuntime implements RequestRuntimeInterface
{
public function method(): string
{
$method = strtoupper(trim((string) ($this->context()['method'] ?? '')));
if ($method === '') {
return 'GET';
}
return substr($method, 0, 8);
}
public function path(): string
{
return trim((string) ($this->context()['path'] ?? ''));
}
public function ip(): string
{
return trim((string) ($this->context()['ip'] ?? ''));
}
public function userAgent(): string
{
return trim((string) ($this->context()['user_agent'] ?? ''));
}
/**
* @return array<string, mixed>
*/
public function queryParams(): array
{
/** @var array<string, mixed> $query */
$query = $_GET;
return $query;
}
public function isSecure(): bool
{
$https = strtolower(trim((string) ($_SERVER['HTTPS'] ?? '')));
if (in_array($https, ['on', '1', 'true'], true)) {
return true;
}
$forwarded = strtolower(trim((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')));
if ($forwarded === '') {
return false;
}
$parts = explode(',', $forwarded);
return trim((string) ($parts[0] ?? '')) === 'https';
}
/**
* @return array<string, mixed>
*/
private function context(): array
{
RequestContext::ensureStarted();
return RequestContext::context();
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace MintyPHP\Http;
interface RequestRuntimeInterface
{
public function method(): string;
public function path(): string;
public function ip(): string;
public function userAgent(): string;
/**
* @return array<string, mixed>
*/
public function queryParams(): array;
public function isSecure(): bool;
}

50
lib/Http/SessionStore.php Normal file
View File

@@ -0,0 +1,50 @@
<?php
namespace MintyPHP\Http;
class SessionStore implements SessionStoreInterface
{
public function get(string $key, mixed $default = null): mixed
{
$session = $this->all();
if (!array_key_exists($key, $session)) {
return $default;
}
return $session[$key];
}
public function set(string $key, mixed $value): void
{
if (!is_array($_SESSION ?? null)) {
$_SESSION = [];
}
$_SESSION[$key] = $value;
}
public function has(string $key): bool
{
$session = $this->all();
return array_key_exists($key, $session);
}
public function remove(string $key): void
{
if (!is_array($_SESSION ?? null)) {
$_SESSION = [];
}
unset($_SESSION[$key]);
}
/**
* @return array<string, mixed>
*/
public function all(): array
{
/** @var array<string, mixed> $session */
$session = $_SESSION;
return $session;
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace MintyPHP\Http;
interface SessionStoreInterface
{
public function get(string $key, mixed $default = null): mixed;
public function set(string $key, mixed $value): void;
public function has(string $key): bool;
public function remove(string $key): void;
/**
* @return array<string, mixed>
*/
public function all(): array;
}