Rename the top-level lib/ directory to core/ so the project root immediately communicates which code is core platform and which lives in modules/. PHP namespaces (MintyPHP\*) are unchanged — only the Composer PSR-4 path mapping moves from lib/ to core/. Module-internal lib/ directories (modules/*/lib/) are untouched. Updated across the full stack: - composer.json PSR-4 mapping - bootstrap entry points (web/index.php, bin/*, tests/bootstrap.php) - tooling configs (phpstan.neon, phpunit.xml, php-cs-fixer) - 26 architecture contract tests - enforcement-policy, guard-catalog, quality-gates - all documentation (CLAUDE.md, README, 25 docs/, .agents/skills/) - bin/qa-extended.sh search paths - .claude/settings.local.json permission paths Workflow: .agents/runs/CORE-LIB-RENAME-001/ (Analyst → Planner → Executor → Code Review (4 findings fixed) → Security Review → Acceptance Test → Finalizer) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
140 lines
4.4 KiB
PHP
140 lines
4.4 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Service\Access;
|
|
|
|
use MintyPHP\App\Module\ModuleRegistry;
|
|
|
|
final class UiAccessService
|
|
{
|
|
/** @var array<string, bool> */
|
|
private array $decisionCache = [];
|
|
|
|
public function __construct(
|
|
private readonly AuthorizationService $authorizationService,
|
|
private readonly ?ModuleRegistry $moduleRegistry = null
|
|
) {
|
|
}
|
|
|
|
public function allow(int $actorUserId, string $ability, array $context = []): bool
|
|
{
|
|
$ability = trim($ability);
|
|
if ($actorUserId <= 0 || $ability === '') {
|
|
return false;
|
|
}
|
|
|
|
// Cache key hashes a sorted context so order-independent inputs produce the same key.
|
|
$normalizedContext = $this->normalizeContext($context);
|
|
$cacheKey = $actorUserId . '|' . $ability . '|' . sha1(json_encode($normalizedContext, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '');
|
|
if (array_key_exists($cacheKey, $this->decisionCache)) {
|
|
return $this->decisionCache[$cacheKey];
|
|
}
|
|
|
|
$decision = $this->authorizationService->authorize($ability, [
|
|
'actor_user_id' => $actorUserId,
|
|
...$normalizedContext,
|
|
]);
|
|
|
|
return $this->decisionCache[$cacheKey] = $decision->isAllowed();
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string|array{ability: string, context?: array<string, mixed>}> $map
|
|
* @param array<string, mixed> $baseContext
|
|
* @return array<string, bool>
|
|
*/
|
|
public function resolveMap(int $actorUserId, array $map, array $baseContext = []): array
|
|
{
|
|
$resolved = [];
|
|
$normalizedBaseContext = $this->normalizeContext($baseContext);
|
|
|
|
foreach ($map as $key => $definition) {
|
|
if (is_string($definition)) {
|
|
$resolved[$key] = $this->allow($actorUserId, $definition, $normalizedBaseContext);
|
|
continue;
|
|
}
|
|
|
|
if (!is_array($definition)) {
|
|
$resolved[$key] = false;
|
|
continue;
|
|
}
|
|
|
|
$ability = $definition['ability'];
|
|
$context = [];
|
|
if (array_key_exists('context', $definition) && is_array($definition['context'])) {
|
|
$context = $this->normalizeContext($definition['context']);
|
|
}
|
|
$resolved[$key] = $this->allow($actorUserId, $ability, [
|
|
...$normalizedBaseContext,
|
|
...$context,
|
|
]);
|
|
}
|
|
|
|
return $resolved;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string|array{ability: string, context?: array<string, mixed>}> $map
|
|
* @param array<string, mixed> $baseContext
|
|
* @return array<string, bool>
|
|
*/
|
|
public function pageCapabilities(int $actorUserId, array $map, array $baseContext = []): array
|
|
{
|
|
return $this->resolveMap($actorUserId, $map, $baseContext);
|
|
}
|
|
|
|
/**
|
|
* Resolve layout capabilities from Core + module-contributed capabilities.
|
|
*
|
|
* @return array<string, bool>
|
|
*/
|
|
public function layoutCapabilities(int $actorUserId): array
|
|
{
|
|
$map = UiCapabilityMap::LAYOUT;
|
|
|
|
// Merge module UI slot abilities directly into the capability map.
|
|
// Each ability string is used as both key and value (no indirection).
|
|
if ($this->moduleRegistry !== null) {
|
|
try {
|
|
foreach ($this->moduleRegistry->getModuleUiAbilities() as $ability) {
|
|
$map[$ability] = $ability;
|
|
}
|
|
} catch (\Throwable) {
|
|
// fail-open: no module registry available in this context
|
|
}
|
|
}
|
|
|
|
return $this->resolveMap($actorUserId, $map);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function normalizeContext(array $context): array
|
|
{
|
|
$normalized = $this->normalizeValue($context);
|
|
return is_array($normalized) ? $normalized : [];
|
|
}
|
|
|
|
private function normalizeValue(mixed $value): mixed
|
|
{
|
|
if (!is_array($value)) {
|
|
return $value;
|
|
}
|
|
|
|
$isList = array_is_list($value);
|
|
if ($isList) {
|
|
foreach ($value as $idx => $entry) {
|
|
$value[$idx] = $this->normalizeValue($entry);
|
|
}
|
|
return $value;
|
|
}
|
|
|
|
ksort($value);
|
|
foreach ($value as $key => $entry) {
|
|
$value[$key] = $this->normalizeValue($entry);
|
|
}
|
|
return $value;
|
|
}
|
|
}
|