forked from fa/breadcrumb-the-shire
Remove subset/duplicate architecture tests already covered by broader checks, and replace assertTrue(true) with self::addToAssertionCount(1) for explicit no-exception-thrown intent. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
444 lines
19 KiB
PHP
444 lines
19 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Service\Access;
|
|
|
|
use MintyPHP\Service\Tenant\TenantScopeService;
|
|
|
|
class UserAuthorizationPolicy implements AuthorizationPolicyInterface
|
|
{
|
|
use AuthorizationPolicyContextTrait;
|
|
|
|
public const ABILITY_ADMIN_USERS_VIEW = 'admin.users.view';
|
|
public const ABILITY_ADMIN_USERS_CREATE = 'admin.users.create';
|
|
public const ABILITY_ADMIN_USERS_EDIT_CONTEXT = 'admin.users.edit.context';
|
|
public const ABILITY_ADMIN_USERS_EDIT_SUBMIT = 'admin.users.edit.submit';
|
|
public const ABILITY_ADMIN_USERS_ACTIVATE = 'admin.users.activate';
|
|
public const ABILITY_ADMIN_USERS_DEACTIVATE = 'admin.users.deactivate';
|
|
public const ABILITY_ADMIN_USERS_DELETE = 'admin.users.delete';
|
|
public const ABILITY_ADMIN_USERS_BULK = 'admin.users.bulk';
|
|
public const ABILITY_ADMIN_USERS_ACCESS_PDF = 'admin.users.access_pdf';
|
|
public const ABILITY_ADMIN_USERS_ACCESS_PDF_BULK = 'admin.users.access_pdf_bulk';
|
|
public const ABILITY_ADMIN_USERS_AVATAR_UPLOAD = 'admin.users.avatar.upload';
|
|
public const ABILITY_ADMIN_USERS_AVATAR_DELETE = 'admin.users.avatar.delete';
|
|
public const ABILITY_ADMIN_USERS_AVATAR_VIEW = 'admin.users.avatar.view';
|
|
public const ABILITY_ADMIN_USERS_API_TOKENS_MANAGE = 'admin.users.api_tokens_manage';
|
|
public const ABILITY_ADMIN_USERS_SEND_ACCESS = 'admin.users.send_access';
|
|
public const ABILITY_ADMIN_USERS_FORGET_TOKENS = 'admin.users.forget_tokens';
|
|
public const ABILITY_API_USERS_INDEX_GET = 'api.users.index.get';
|
|
public const ABILITY_API_USERS_INDEX_POST = 'api.users.index.post';
|
|
public const ABILITY_API_USERS_SHOW_GET = 'api.users.show.get';
|
|
public const ABILITY_API_USERS_SHOW_UPDATE = 'api.users.show.update';
|
|
public const ABILITY_API_USERS_SHOW_DELETE = 'api.users.show.delete';
|
|
|
|
public function __construct(
|
|
private readonly PermissionService $permissionService,
|
|
private readonly TenantScopeService $scopeGateway
|
|
) {
|
|
}
|
|
|
|
public function supports(string $ability): bool
|
|
{
|
|
return in_array($ability, [
|
|
self::ABILITY_ADMIN_USERS_VIEW,
|
|
self::ABILITY_ADMIN_USERS_CREATE,
|
|
self::ABILITY_ADMIN_USERS_EDIT_CONTEXT,
|
|
self::ABILITY_ADMIN_USERS_EDIT_SUBMIT,
|
|
self::ABILITY_ADMIN_USERS_ACTIVATE,
|
|
self::ABILITY_ADMIN_USERS_DEACTIVATE,
|
|
self::ABILITY_ADMIN_USERS_DELETE,
|
|
self::ABILITY_ADMIN_USERS_BULK,
|
|
self::ABILITY_ADMIN_USERS_ACCESS_PDF,
|
|
self::ABILITY_ADMIN_USERS_ACCESS_PDF_BULK,
|
|
self::ABILITY_ADMIN_USERS_AVATAR_UPLOAD,
|
|
self::ABILITY_ADMIN_USERS_AVATAR_DELETE,
|
|
self::ABILITY_ADMIN_USERS_AVATAR_VIEW,
|
|
self::ABILITY_ADMIN_USERS_API_TOKENS_MANAGE,
|
|
self::ABILITY_ADMIN_USERS_SEND_ACCESS,
|
|
self::ABILITY_ADMIN_USERS_FORGET_TOKENS,
|
|
self::ABILITY_API_USERS_INDEX_GET,
|
|
self::ABILITY_API_USERS_INDEX_POST,
|
|
self::ABILITY_API_USERS_SHOW_GET,
|
|
self::ABILITY_API_USERS_SHOW_UPDATE,
|
|
self::ABILITY_API_USERS_SHOW_DELETE,
|
|
], true);
|
|
}
|
|
|
|
public function authorize(string $ability, array $context = []): AuthorizationDecision
|
|
{
|
|
return match ($ability) {
|
|
self::ABILITY_ADMIN_USERS_VIEW => $this->authorizeAdminUsersView($context),
|
|
self::ABILITY_ADMIN_USERS_CREATE => $this->authorizeAdminUsersCreate($context),
|
|
self::ABILITY_ADMIN_USERS_EDIT_CONTEXT => $this->authorizeAdminUsersEditContext($context),
|
|
self::ABILITY_ADMIN_USERS_EDIT_SUBMIT => $this->authorizeAdminUsersEditSubmit($context),
|
|
self::ABILITY_ADMIN_USERS_ACTIVATE,
|
|
self::ABILITY_ADMIN_USERS_DEACTIVATE => $this->authorizeAdminUserStatusUpdate($context),
|
|
self::ABILITY_ADMIN_USERS_DELETE => $this->authorizeAdminUserDelete($context),
|
|
self::ABILITY_ADMIN_USERS_BULK => $this->authorizeAdminUsersBulk($context),
|
|
self::ABILITY_ADMIN_USERS_ACCESS_PDF => $this->authorizeAdminUserAccessPdf($context),
|
|
self::ABILITY_ADMIN_USERS_ACCESS_PDF_BULK => $this->authorizeAdminUsersAccessPdfBulk($context),
|
|
self::ABILITY_ADMIN_USERS_AVATAR_UPLOAD,
|
|
self::ABILITY_ADMIN_USERS_AVATAR_DELETE => $this->authorizeAdminUsersAvatarMutate($context),
|
|
self::ABILITY_ADMIN_USERS_AVATAR_VIEW => $this->authorizeAdminUsersAvatarView($context),
|
|
self::ABILITY_ADMIN_USERS_API_TOKENS_MANAGE => $this->authorizeAdminUsersApiTokensManage($context),
|
|
self::ABILITY_ADMIN_USERS_SEND_ACCESS,
|
|
self::ABILITY_ADMIN_USERS_FORGET_TOKENS => $this->authorizeAdminUserSelfOrUpdate($context),
|
|
self::ABILITY_API_USERS_INDEX_GET => $this->authorizeApiUsersIndexGet($context),
|
|
self::ABILITY_API_USERS_INDEX_POST => $this->authorizeApiUsersIndexPost($context),
|
|
self::ABILITY_API_USERS_SHOW_GET => $this->authorizeApiUsersShow($context, PermissionService::USERS_VIEW),
|
|
self::ABILITY_API_USERS_SHOW_UPDATE => $this->authorizeApiUsersUpdate($context),
|
|
self::ABILITY_API_USERS_SHOW_DELETE => $this->authorizeApiUsersShow($context, PermissionService::USERS_DELETE),
|
|
default => AuthorizationDecision::deny(500, 'authorization_ability_not_supported'),
|
|
};
|
|
}
|
|
|
|
private function authorizeAdminUsersView(array $context): AuthorizationDecision
|
|
{
|
|
return $this->authorizeAdminUserWithPermission(PermissionService::USERS_VIEW, $context, false);
|
|
}
|
|
|
|
private function authorizeAdminUsersCreate(array $context): AuthorizationDecision
|
|
{
|
|
return $this->authorizeAdminUserWithPermission(PermissionService::USERS_CREATE, $context, false);
|
|
}
|
|
|
|
// Returns the full capabilities map as attributes — the submit handler re-uses it to avoid recalculating.
|
|
private function authorizeAdminUsersEditContext(array $context): AuthorizationDecision
|
|
{
|
|
return $this->buildEditContextDecision($context);
|
|
}
|
|
|
|
private function authorizeAdminUsersEditSubmit(array $context): AuthorizationDecision
|
|
{
|
|
$contextDecision = $this->buildEditContextDecision($context);
|
|
if (!$contextDecision->isAllowed()) {
|
|
return $contextDecision;
|
|
}
|
|
|
|
$capabilities = $this->capabilitiesFromDecision($contextDecision);
|
|
if (!($capabilities['can_edit_user'] ?? false)) {
|
|
return AuthorizationDecision::deny(403, 'forbidden');
|
|
}
|
|
|
|
return AuthorizationDecision::allow(['capabilities' => $capabilities]);
|
|
}
|
|
|
|
private function authorizeAdminUserStatusUpdate(array $context): AuthorizationDecision
|
|
{
|
|
return $this->authorizeAdminUserWithPermission(PermissionService::USERS_UPDATE, $context);
|
|
}
|
|
|
|
private function authorizeAdminUserDelete(array $context): AuthorizationDecision
|
|
{
|
|
return $this->authorizeAdminUserWithPermission(PermissionService::USERS_DELETE, $context);
|
|
}
|
|
|
|
private function authorizeAdminUserAccessPdf(array $context): AuthorizationDecision
|
|
{
|
|
return $this->authorizeAdminUserWithPermission(PermissionService::USERS_ACCESS_PDF, $context);
|
|
}
|
|
|
|
private function authorizeAdminUsersAccessPdfBulk(array $context): AuthorizationDecision
|
|
{
|
|
return $this->authorizeAdminUserWithPermission(PermissionService::USERS_ACCESS_PDF, $context, false);
|
|
}
|
|
|
|
private function authorizeAdminUsersApiTokensManage(array $context): AuthorizationDecision
|
|
{
|
|
return $this->authorizeAdminUserWithPermission(PermissionService::API_TOKENS_MANAGE, $context);
|
|
}
|
|
|
|
private function authorizeAdminUsersAvatarMutate(array $context): AuthorizationDecision
|
|
{
|
|
$actorUserId = $this->actorUserId($context);
|
|
$targetUserId = (int) ($context['target_user_id'] ?? 0);
|
|
if ($actorUserId <= 0 || $targetUserId <= 0) {
|
|
return AuthorizationDecision::deny(403, 'forbidden');
|
|
}
|
|
|
|
$isOwnAccount = $actorUserId === $targetUserId;
|
|
if (!$isOwnAccount && !$this->scopeGateway->canAccess('users', $targetUserId, $actorUserId)) {
|
|
return AuthorizationDecision::deny(403, 'permission_denied');
|
|
}
|
|
|
|
$canEditUser = $this->canEditUserForTarget($actorUserId, $targetUserId);
|
|
if (!$canEditUser) {
|
|
return AuthorizationDecision::deny(403, 'forbidden');
|
|
}
|
|
|
|
return AuthorizationDecision::allow();
|
|
}
|
|
|
|
private function authorizeAdminUsersAvatarView(array $context): AuthorizationDecision
|
|
{
|
|
$actorUserId = $this->actorUserId($context);
|
|
$targetUserId = (int) ($context['target_user_id'] ?? 0);
|
|
if ($actorUserId <= 0 || $targetUserId <= 0) {
|
|
return AuthorizationDecision::deny(403, 'forbidden');
|
|
}
|
|
|
|
if ($actorUserId === $targetUserId) {
|
|
return AuthorizationDecision::allow();
|
|
}
|
|
|
|
if (!$this->scopeGateway->canAccess('users', $targetUserId, $actorUserId)) {
|
|
return AuthorizationDecision::deny(403, 'permission_denied');
|
|
}
|
|
|
|
$canEditUser = $this->canEditUserForTarget($actorUserId, $targetUserId);
|
|
if (!$this->hasPermission($actorUserId, PermissionService::USERS_VIEW)
|
|
&& !$this->hasPermission($actorUserId, 'address_book.view') // Registered by addressbook module when active
|
|
&& !$canEditUser) {
|
|
return AuthorizationDecision::deny(403, 'forbidden');
|
|
}
|
|
|
|
return AuthorizationDecision::allow();
|
|
}
|
|
|
|
private function authorizeAdminUserSelfOrUpdate(array $context): AuthorizationDecision
|
|
{
|
|
$actorUserId = $this->actorUserId($context);
|
|
$targetUserId = (int) ($context['target_user_id'] ?? 0);
|
|
if ($targetUserId <= 0) {
|
|
return AuthorizationDecision::deny(403, 'forbidden');
|
|
}
|
|
|
|
if ($this->canEditUserForTarget($actorUserId, $targetUserId)) {
|
|
if ($targetUserId !== $actorUserId
|
|
&& !$this->scopeGateway->canAccess('users', $targetUserId, $actorUserId)) {
|
|
return AuthorizationDecision::deny(403, 'permission_denied');
|
|
}
|
|
return AuthorizationDecision::allow();
|
|
}
|
|
|
|
return AuthorizationDecision::deny(403, 'forbidden');
|
|
}
|
|
|
|
private function authorizeAdminUsersBulk(array $context): AuthorizationDecision
|
|
{
|
|
$actorUserId = $this->actorUserId($context);
|
|
$action = strtolower(trim((string) ($context['bulk_action'] ?? '')));
|
|
|
|
$permission = match ($action) {
|
|
'delete' => PermissionService::USERS_DELETE,
|
|
'activate', 'deactivate', 'send-access' => PermissionService::USERS_UPDATE,
|
|
default => null,
|
|
};
|
|
|
|
if ($permission === null) {
|
|
return AuthorizationDecision::deny(400, 'invalid_action');
|
|
}
|
|
if (!$this->hasPermission($actorUserId, $permission)) {
|
|
return AuthorizationDecision::deny(403, 'forbidden');
|
|
}
|
|
|
|
return AuthorizationDecision::allow();
|
|
}
|
|
|
|
private function authorizeApiUsersIndexGet(array $context): AuthorizationDecision
|
|
{
|
|
$actorUserId = $this->actorUserId($context);
|
|
if (!$this->hasPermission($actorUserId, PermissionService::USERS_VIEW)) {
|
|
return AuthorizationDecision::deny(403, 'forbidden');
|
|
}
|
|
|
|
return AuthorizationDecision::allow();
|
|
}
|
|
|
|
private function authorizeApiUsersIndexPost(array $context): AuthorizationDecision
|
|
{
|
|
$actorUserId = $this->actorUserId($context);
|
|
if (!$this->hasPermission($actorUserId, PermissionService::USERS_CREATE)) {
|
|
return AuthorizationDecision::deny(403, 'forbidden');
|
|
}
|
|
|
|
$input = is_array($context['input'] ?? null) ? $context['input'] : [];
|
|
$scopedTenantId = (int) ($context['scoped_tenant_id'] ?? 0);
|
|
if ($scopedTenantId <= 0) {
|
|
return AuthorizationDecision::allow(['input' => $input]);
|
|
}
|
|
|
|
if ($this->containsOutOfScopeTenantId($input['tenant_ids'] ?? [], $scopedTenantId)) {
|
|
return AuthorizationDecision::deny(403, 'tenant_scoped_token_forbidden');
|
|
}
|
|
|
|
$primaryTenantId = (int) ($input['primary_tenant_id'] ?? 0);
|
|
if ($primaryTenantId > 0 && $primaryTenantId !== $scopedTenantId) {
|
|
return AuthorizationDecision::deny(403, 'tenant_scoped_token_forbidden');
|
|
}
|
|
|
|
$input['tenant_ids'] = [$scopedTenantId];
|
|
$input['primary_tenant_id'] = $scopedTenantId;
|
|
|
|
return AuthorizationDecision::allow(['input' => $input]);
|
|
}
|
|
|
|
private function authorizeApiUsersUpdate(array $context): AuthorizationDecision
|
|
{
|
|
$decision = $this->authorizeApiUsersShow($context, PermissionService::USERS_UPDATE);
|
|
if (!$decision->isAllowed()) {
|
|
return $decision;
|
|
}
|
|
|
|
$input = is_array($context['input'] ?? null) ? $context['input'] : [];
|
|
$scopedTenantId = (int) ($context['scoped_tenant_id'] ?? 0);
|
|
if ($scopedTenantId <= 0) {
|
|
return AuthorizationDecision::allow(['input' => $input]);
|
|
}
|
|
|
|
if (array_key_exists('tenant_ids', $input)
|
|
&& $this->containsOutOfScopeTenantId($input['tenant_ids'], $scopedTenantId)) {
|
|
return AuthorizationDecision::deny(403, 'tenant_scoped_token_forbidden');
|
|
}
|
|
|
|
if (array_key_exists('primary_tenant_id', $input)) {
|
|
$primaryTenantId = (int) ($input['primary_tenant_id'] ?? 0);
|
|
if ($primaryTenantId > 0 && $primaryTenantId !== $scopedTenantId) {
|
|
return AuthorizationDecision::deny(403, 'tenant_scoped_token_forbidden');
|
|
}
|
|
}
|
|
|
|
return AuthorizationDecision::allow(['input' => $input]);
|
|
}
|
|
|
|
private function authorizeApiUsersShow(array $context, string $permissionKey): AuthorizationDecision
|
|
{
|
|
$actorUserId = $this->actorUserId($context);
|
|
if (!$this->hasPermission($actorUserId, $permissionKey)) {
|
|
return AuthorizationDecision::deny(403, 'forbidden');
|
|
}
|
|
|
|
$targetUserId = (int) ($context['target_user_id'] ?? 0);
|
|
if ($targetUserId <= 0) {
|
|
return AuthorizationDecision::allow();
|
|
}
|
|
|
|
// 404 instead of 403 — don't reveal that the resource exists outside the actor's scope.
|
|
if (!$this->scopeGateway->canAccess('users', $targetUserId, $actorUserId)) {
|
|
return AuthorizationDecision::deny(404, 'not_found');
|
|
}
|
|
|
|
$scopedTenantId = (int) ($context['scoped_tenant_id'] ?? 0);
|
|
if ($scopedTenantId > 0 && !$this->scopeGateway->resourceBelongsToTenant('users', $targetUserId, $scopedTenantId)) {
|
|
return AuthorizationDecision::deny(404, 'not_found');
|
|
}
|
|
|
|
return AuthorizationDecision::allow();
|
|
}
|
|
|
|
private function containsOutOfScopeTenantId(mixed $tenantIdsRaw, int $scopedTenantId): bool
|
|
{
|
|
$tenantIds = $this->normalizeTenantIds($tenantIdsRaw);
|
|
if (!$tenantIds) {
|
|
return false;
|
|
}
|
|
|
|
return array_diff($tenantIds, [$scopedTenantId]) !== [];
|
|
}
|
|
|
|
private function normalizeTenantIds(mixed $tenantIdsRaw): array
|
|
{
|
|
if (!is_array($tenantIdsRaw)) {
|
|
$tenantIdsRaw = [$tenantIdsRaw];
|
|
}
|
|
|
|
$tenantIds = [];
|
|
foreach ($tenantIdsRaw as $value) {
|
|
$tenantId = (int) $value;
|
|
if ($tenantId > 0) {
|
|
$tenantIds[$tenantId] = $tenantId;
|
|
}
|
|
}
|
|
|
|
return array_values($tenantIds);
|
|
}
|
|
|
|
private function canEditUserForTarget(int $actorUserId, int $targetUserId): bool
|
|
{
|
|
if ($actorUserId <= 0 || $targetUserId <= 0) {
|
|
return false;
|
|
}
|
|
|
|
$canUpdateUser = $this->hasPermission($actorUserId, PermissionService::USERS_UPDATE);
|
|
if ($targetUserId === $actorUserId) {
|
|
return $canUpdateUser || $this->hasPermission($actorUserId, PermissionService::USERS_SELF_UPDATE);
|
|
}
|
|
|
|
return $canUpdateUser;
|
|
}
|
|
|
|
private function buildEditContextDecision(array $context): AuthorizationDecision
|
|
{
|
|
$actorUserId = $this->actorUserId($context);
|
|
$targetUserId = (int) ($context['target_user_id'] ?? 0);
|
|
if ($actorUserId <= 0 || $targetUserId <= 0) {
|
|
return AuthorizationDecision::deny(403, 'forbidden');
|
|
}
|
|
|
|
$isOwnAccount = $targetUserId === $actorUserId;
|
|
if (!$isOwnAccount && !$this->scopeGateway->canAccess('users', $targetUserId, $actorUserId)) {
|
|
return AuthorizationDecision::deny(403, 'permission_denied');
|
|
}
|
|
|
|
$canViewUsers = $this->hasPermission($actorUserId, PermissionService::USERS_VIEW);
|
|
$canEditUser = $this->canEditUserForTarget($actorUserId, $targetUserId);
|
|
$canViewPage = $canViewUsers || $canEditUser;
|
|
if (!$canViewPage) {
|
|
return AuthorizationDecision::deny(403, 'forbidden');
|
|
}
|
|
|
|
$canManageTenants = $this->hasPermission($actorUserId, PermissionService::TENANTS_UPDATE);
|
|
$allowedTenantIds = $canManageTenants ? null : $this->scopeGateway->getUserTenantIds($actorUserId);
|
|
$canViewUserAudit = $this->hasPermission($actorUserId, PermissionService::USERS_VIEW_AUDIT);
|
|
|
|
$capabilities = [
|
|
'can_view_page' => $canViewPage,
|
|
'can_view_users' => $canViewUsers,
|
|
'can_edit_user' => $canEditUser,
|
|
'can_edit_assignments' => $canEditUser && $this->hasPermission($actorUserId, PermissionService::USERS_UPDATE_ASSIGNMENTS),
|
|
'can_manage_tenants' => $canManageTenants,
|
|
'can_edit_custom_field_values' => $canEditUser && (
|
|
$this->hasPermission($actorUserId, PermissionService::CUSTOM_FIELDS_EDIT_VALUES)
|
|
|| ($isOwnAccount && $this->hasPermission($actorUserId, PermissionService::USERS_SELF_UPDATE))
|
|
),
|
|
'can_manage_api_tokens' => $canEditUser && $this->hasPermission($actorUserId, PermissionService::API_TOKENS_MANAGE),
|
|
'can_view_address_book' => $this->hasPermission($actorUserId, 'address_book.view'), // Registered by addressbook module when active
|
|
'can_view_user_meta' => $this->hasPermission($actorUserId, PermissionService::USERS_VIEW_META),
|
|
'can_view_user_audit' => $canViewUserAudit,
|
|
'can_access_pdf' => $this->hasPermission($actorUserId, PermissionService::USERS_ACCESS_PDF),
|
|
'can_delete_user' => !$isOwnAccount && $this->hasPermission($actorUserId, PermissionService::USERS_DELETE),
|
|
'can_view_security_artifacts' => $isOwnAccount || $canViewUserAudit,
|
|
'can_view_permissions_table' => $this->hasPermission($actorUserId, PermissionService::PERMISSIONS_VIEW),
|
|
'is_own_account' => $isOwnAccount,
|
|
'allowed_tenant_ids' => is_array($allowedTenantIds)
|
|
? array_values(array_unique(array_map('intval', $allowedTenantIds)))
|
|
: null,
|
|
];
|
|
|
|
return AuthorizationDecision::allow(['capabilities' => $capabilities]);
|
|
}
|
|
|
|
private function authorizeAdminUserWithPermission(
|
|
string $permissionKey,
|
|
array $context,
|
|
bool $checkTenantScope = true
|
|
): AuthorizationDecision {
|
|
$actorUserId = $this->actorUserId($context);
|
|
if (!$this->hasPermission($actorUserId, $permissionKey)) {
|
|
return AuthorizationDecision::deny(403, 'forbidden');
|
|
}
|
|
|
|
if (!$checkTenantScope) {
|
|
return AuthorizationDecision::allow();
|
|
}
|
|
|
|
$targetUserId = (int) ($context['target_user_id'] ?? 0);
|
|
if ($targetUserId > 0
|
|
&& $targetUserId !== $actorUserId
|
|
&& !$this->scopeGateway->canAccess('users', $targetUserId, $actorUserId)) {
|
|
return AuthorizationDecision::deny(403, 'permission_denied');
|
|
}
|
|
|
|
return AuthorizationDecision::allow();
|
|
}
|
|
}
|