Align commerce module with the monorepo module structure by updating all namespaces to use the Core\Mod\Commerce convention. This change supports the recent monorepo separation and ensures consistency with other modules. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
415 lines
13 KiB
PHP
415 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Core\Mod\Commerce\Services;
|
|
|
|
use Core\Mod\Commerce\Models\Entity;
|
|
use Core\Mod\Commerce\Models\PermissionMatrix;
|
|
use Core\Mod\Commerce\Models\PermissionRequest;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Collection;
|
|
|
|
/**
|
|
* Permission Matrix Service - enforces top-down immutable permissions.
|
|
*
|
|
* Rules:
|
|
* - If M1 says "NO" → Everything below is "NO"
|
|
* - If M1 says "YES" → M2 can say "NO" for itself
|
|
* - Permissions cascade DOWN, restrictions are IMMUTABLE from above
|
|
*/
|
|
class PermissionMatrixService
|
|
{
|
|
protected bool $trainingMode;
|
|
|
|
protected bool $strictMode;
|
|
|
|
protected bool $logAllChecks;
|
|
|
|
protected bool $logDenials;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->trainingMode = config('commerce.matrix.training_mode', false);
|
|
$this->strictMode = config('commerce.matrix.strict_mode', true);
|
|
$this->logAllChecks = config('commerce.matrix.log_all_checks', false);
|
|
$this->logDenials = config('commerce.matrix.log_denials', true);
|
|
}
|
|
|
|
/**
|
|
* Check if an entity can perform an action.
|
|
*/
|
|
public function can(Entity $entity, string $key, ?string $scope = null): PermissionResult
|
|
{
|
|
// Build the hierarchy path (M1 → M2 → M3)
|
|
$hierarchy = $this->getHierarchy($entity);
|
|
|
|
// Check from top down (M1 first, ancestors)
|
|
foreach ($hierarchy as $ancestor) {
|
|
$permission = PermissionMatrix::where('entity_id', $ancestor->id)
|
|
->where('key', $key)
|
|
->where(function ($q) use ($scope) {
|
|
$q->whereNull('scope')->orWhere('scope', $scope);
|
|
})
|
|
->first();
|
|
|
|
if ($permission) {
|
|
// If locked and denied at this level, everything below is denied
|
|
if ($permission->locked && ! $permission->allowed) {
|
|
return PermissionResult::denied(
|
|
reason: "Locked by {$ancestor->name}",
|
|
lockedBy: $ancestor
|
|
);
|
|
}
|
|
|
|
// If explicitly denied (not locked), continue checking
|
|
if (! $permission->allowed && ! $permission->locked) {
|
|
return PermissionResult::denied(
|
|
reason: "Denied by {$ancestor->name}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check the entity itself
|
|
$ownPermission = PermissionMatrix::where('entity_id', $entity->id)
|
|
->where('key', $key)
|
|
->where(function ($q) use ($scope) {
|
|
$q->whereNull('scope')->orWhere('scope', $scope);
|
|
})
|
|
->first();
|
|
|
|
if ($ownPermission) {
|
|
return $ownPermission->allowed
|
|
? PermissionResult::allowed()
|
|
: PermissionResult::denied(reason: 'Denied by own policy');
|
|
}
|
|
|
|
// No permission found
|
|
return PermissionResult::undefined(key: $key, scope: $scope);
|
|
}
|
|
|
|
/**
|
|
* Gate a request through the matrix.
|
|
*/
|
|
public function gateRequest(Request $request, Entity $entity, string $action): PermissionResult
|
|
{
|
|
$scope = $this->extractScope($request);
|
|
$result = $this->can($entity, $action, $scope);
|
|
|
|
// Log the request if configured
|
|
if ($this->logAllChecks || ($this->logDenials && $result->isDenied())) {
|
|
$this->logRequest($request, $entity, $action, $scope, $result);
|
|
}
|
|
|
|
// Training mode: undefined permissions become pending for approval
|
|
if ($result->isUndefined() && $this->trainingMode) {
|
|
// Log as pending
|
|
PermissionRequest::fromRequest($entity, $action, PermissionRequest::STATUS_PENDING, $scope);
|
|
|
|
return PermissionResult::pending(
|
|
key: $action,
|
|
scope: $scope,
|
|
trainingUrl: route('commerce.matrix.train', [
|
|
'entity' => $entity->id,
|
|
'key' => $action,
|
|
'scope' => $scope,
|
|
])
|
|
);
|
|
}
|
|
|
|
// Production mode (strict): undefined = denied
|
|
if ($result->isUndefined() && $this->strictMode) {
|
|
return PermissionResult::denied(
|
|
reason: "No permission defined for {$action}"
|
|
);
|
|
}
|
|
|
|
// Non-strict mode with undefined: check default_allow config
|
|
if ($result->isUndefined()) {
|
|
$defaultAllow = config('commerce.matrix.default_allow', false);
|
|
|
|
return $defaultAllow
|
|
? PermissionResult::allowed()
|
|
: PermissionResult::denied(reason: "No permission defined for {$action}");
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Train a permission (dev mode).
|
|
*/
|
|
public function train(
|
|
Entity $entity,
|
|
string $key,
|
|
?string $scope,
|
|
bool $allow,
|
|
?string $route = null
|
|
): PermissionMatrix {
|
|
// Check if parent has locked this
|
|
$hierarchy = $this->getHierarchy($entity);
|
|
|
|
foreach ($hierarchy as $ancestor) {
|
|
$parentPerm = PermissionMatrix::where('entity_id', $ancestor->id)
|
|
->where('key', $key)
|
|
->where('locked', true)
|
|
->first();
|
|
|
|
if ($parentPerm && ! $parentPerm->allowed) {
|
|
throw new PermissionLockedException(
|
|
"Cannot train permission '{$key}' - locked by {$ancestor->name}"
|
|
);
|
|
}
|
|
}
|
|
|
|
return PermissionMatrix::updateOrCreate(
|
|
[
|
|
'entity_id' => $entity->id,
|
|
'key' => $key,
|
|
'scope' => $scope,
|
|
],
|
|
[
|
|
'allowed' => $allow,
|
|
'locked' => false,
|
|
'source' => PermissionMatrix::SOURCE_TRAINED,
|
|
'trained_at' => now(),
|
|
'trained_route' => $route,
|
|
]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Lock a permission (cascades down).
|
|
*/
|
|
public function lock(Entity $entity, string $key, bool $allowed, ?string $scope = null): void
|
|
{
|
|
// Set on this entity
|
|
PermissionMatrix::updateOrCreate(
|
|
[
|
|
'entity_id' => $entity->id,
|
|
'key' => $key,
|
|
'scope' => $scope,
|
|
],
|
|
[
|
|
'allowed' => $allowed,
|
|
'locked' => true,
|
|
'source' => PermissionMatrix::SOURCE_EXPLICIT,
|
|
'set_by_entity_id' => $entity->id,
|
|
]
|
|
);
|
|
|
|
// Cascade to all descendants
|
|
$descendants = Entity::where('path', 'like', $entity->path.'/%')->get();
|
|
|
|
foreach ($descendants as $descendant) {
|
|
PermissionMatrix::updateOrCreate(
|
|
[
|
|
'entity_id' => $descendant->id,
|
|
'key' => $key,
|
|
'scope' => $scope,
|
|
],
|
|
[
|
|
'allowed' => $allowed,
|
|
'locked' => true,
|
|
'source' => PermissionMatrix::SOURCE_INHERITED,
|
|
'set_by_entity_id' => $entity->id,
|
|
]
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set an explicit permission (not locked, not trained).
|
|
*/
|
|
public function setPermission(
|
|
Entity $entity,
|
|
string $key,
|
|
bool $allowed,
|
|
?string $scope = null
|
|
): PermissionMatrix {
|
|
// Check if parent has locked this
|
|
$hierarchy = $this->getHierarchy($entity);
|
|
|
|
foreach ($hierarchy as $ancestor) {
|
|
$parentPerm = PermissionMatrix::where('entity_id', $ancestor->id)
|
|
->where('key', $key)
|
|
->where('locked', true)
|
|
->first();
|
|
|
|
if ($parentPerm && ! $parentPerm->allowed && $allowed) {
|
|
throw new PermissionLockedException(
|
|
"Cannot allow permission '{$key}' - locked as denied by {$ancestor->name}"
|
|
);
|
|
}
|
|
}
|
|
|
|
return PermissionMatrix::updateOrCreate(
|
|
[
|
|
'entity_id' => $entity->id,
|
|
'key' => $key,
|
|
'scope' => $scope,
|
|
],
|
|
[
|
|
'allowed' => $allowed,
|
|
'locked' => false,
|
|
'source' => PermissionMatrix::SOURCE_EXPLICIT,
|
|
]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Unlock a permission (removes inherited locks from descendants).
|
|
*/
|
|
public function unlock(Entity $entity, string $key, ?string $scope = null): void
|
|
{
|
|
// Update this entity's permission to unlocked
|
|
PermissionMatrix::where('entity_id', $entity->id)
|
|
->where('key', $key)
|
|
->where('scope', $scope)
|
|
->update(['locked' => false, 'source' => PermissionMatrix::SOURCE_EXPLICIT]);
|
|
|
|
// Remove inherited locks from descendants
|
|
$descendantIds = Entity::where('path', 'like', $entity->path.'/%')
|
|
->pluck('id');
|
|
|
|
PermissionMatrix::whereIn('entity_id', $descendantIds)
|
|
->where('key', $key)
|
|
->where('scope', $scope)
|
|
->where('set_by_entity_id', $entity->id)
|
|
->delete();
|
|
}
|
|
|
|
/**
|
|
* Get all permissions for an entity.
|
|
*/
|
|
public function getPermissions(Entity $entity): Collection
|
|
{
|
|
return PermissionMatrix::where('entity_id', $entity->id)
|
|
->orderBy('key')
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* Get effective permissions for an entity (including inherited).
|
|
*/
|
|
public function getEffectivePermissions(Entity $entity): Collection
|
|
{
|
|
$hierarchy = $this->getHierarchy($entity);
|
|
$hierarchy->push($entity);
|
|
|
|
$entityIds = $hierarchy->pluck('id');
|
|
|
|
return PermissionMatrix::whereIn('entity_id', $entityIds)
|
|
->orderBy('key')
|
|
->get()
|
|
->groupBy('key')
|
|
->map(function ($permissions) use ($entity) {
|
|
// For each key, determine the effective permission
|
|
foreach ($permissions as $perm) {
|
|
if ($perm->locked && ! $perm->allowed) {
|
|
return $perm; // Locked denial wins
|
|
}
|
|
}
|
|
|
|
// Return the entity's own permission if exists
|
|
return $permissions->firstWhere('entity_id', $entity->id)
|
|
?? $permissions->last();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get pending permission requests for training.
|
|
*/
|
|
public function getPendingRequests(?Entity $entity = null): Collection
|
|
{
|
|
$query = PermissionRequest::pending()->untrained();
|
|
|
|
if ($entity) {
|
|
$query->forEntity($entity->id);
|
|
}
|
|
|
|
return $query->orderBy('created_at', 'desc')->get();
|
|
}
|
|
|
|
/**
|
|
* Mark pending requests as trained.
|
|
*/
|
|
public function markRequestsTrained(Entity $entity, string $action, ?string $scope = null): int
|
|
{
|
|
return PermissionRequest::forEntity($entity->id)
|
|
->forAction($action)
|
|
->where('scope', $scope)
|
|
->pending()
|
|
->update([
|
|
'was_trained' => true,
|
|
'trained_at' => now(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Check if training mode is enabled.
|
|
*/
|
|
public function isTrainingMode(): bool
|
|
{
|
|
return $this->trainingMode;
|
|
}
|
|
|
|
/**
|
|
* Check if strict mode is enabled.
|
|
*/
|
|
public function isStrictMode(): bool
|
|
{
|
|
return $this->strictMode;
|
|
}
|
|
|
|
/**
|
|
* Get hierarchy from root to parent (not including entity itself).
|
|
*/
|
|
protected function getHierarchy(Entity $entity): Collection
|
|
{
|
|
return $entity->getAncestors();
|
|
}
|
|
|
|
/**
|
|
* Extract scope from request (resource type or ID).
|
|
*/
|
|
protected function extractScope(Request $request): ?string
|
|
{
|
|
// Try route parameters
|
|
$route = $request->route();
|
|
|
|
if ($route) {
|
|
// Look for common resource parameters
|
|
foreach (['id', 'product', 'order', 'customer'] as $param) {
|
|
if ($value = $route->parameter($param)) {
|
|
return is_object($value) ? (string) $value->id : (string) $value;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Log a permission request.
|
|
*/
|
|
protected function logRequest(
|
|
Request $request,
|
|
Entity $entity,
|
|
string $action,
|
|
?string $scope,
|
|
PermissionResult $result
|
|
): void {
|
|
PermissionRequest::fromRequest(
|
|
$entity,
|
|
$action,
|
|
match (true) {
|
|
$result->isAllowed() => PermissionRequest::STATUS_ALLOWED,
|
|
$result->isDenied() => PermissionRequest::STATUS_DENIED,
|
|
default => PermissionRequest::STATUS_PENDING,
|
|
},
|
|
$scope
|
|
);
|
|
}
|
|
}
|