agent foundation
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 === []) {
|
||||
|
||||
@@ -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
34
lib/Http/CookieStore.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
18
lib/Http/CookieStoreInterface.php
Normal file
18
lib/Http/CookieStoreInterface.php
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 !== '') {
|
||||
|
||||
@@ -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'] ?? ''));
|
||||
|
||||
66
lib/Http/RequestRuntime.php
Normal file
66
lib/Http/RequestRuntime.php
Normal 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();
|
||||
}
|
||||
}
|
||||
21
lib/Http/RequestRuntimeInterface.php
Normal file
21
lib/Http/RequestRuntimeInterface.php
Normal 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
50
lib/Http/SessionStore.php
Normal 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;
|
||||
}
|
||||
}
|
||||
19
lib/Http/SessionStoreInterface.php
Normal file
19
lib/Http/SessionStoreInterface.php
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user