1
0
Files
breadcrumb-the-shire/core/Service/Access/UserAuthorizationPolicy.php

442 lines
19 KiB
PHP
Raw Normal View History

<?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);
}
2026-03-06 00:44:52 +01:00
// 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();
}
2026-03-06 00:44:52 +01:00
// 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) ? toIntIds($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();
}
}