Hydrate configurationPolicies/{id}/settings for endpoint security/baseline policies so snapshots include real rule data.
Treat those types like Settings Catalog policies in the normalizer so they show the searchable settings table, recognizable categories, and readable choice values (firewall-specific formatting + interface badge parsing).
Improve “General” tab cards: badge lists for platforms/technologies, template reference summary (name/family/version/ID), and ISO timestamps rendered as YYYY‑MM‑DD HH:MM:SS; added regression test for the view.
Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #23
695 lines
22 KiB
PHP
695 lines
22 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Intune;
|
|
|
|
use App\Models\BackupItem;
|
|
use App\Models\BackupSet;
|
|
use App\Models\Policy;
|
|
use App\Models\PolicyVersion;
|
|
use App\Models\Tenant;
|
|
use App\Services\Graph\GroupResolver;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Str;
|
|
|
|
class RestoreRiskChecker
|
|
{
|
|
public function __construct(
|
|
private readonly GroupResolver $groupResolver,
|
|
) {}
|
|
|
|
/**
|
|
* @param array<int>|null $selectedItemIds
|
|
* @param array<string, string|null> $groupMapping
|
|
* @return array{summary: array{blocking: int, warning: int, safe: int, has_blockers: bool}, results: array<int, array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}>}
|
|
*/
|
|
public function check(Tenant $tenant, BackupSet $backupSet, ?array $selectedItemIds = null, array $groupMapping = []): array
|
|
{
|
|
if ($backupSet->tenant_id !== $tenant->id) {
|
|
throw new \InvalidArgumentException('Backup set does not belong to the provided tenant.');
|
|
}
|
|
|
|
$items = $this->loadItems($backupSet, $selectedItemIds);
|
|
|
|
$policyItems = $items
|
|
->reject(fn (BackupItem $item): bool => $item->isFoundation())
|
|
->values();
|
|
|
|
$results = [];
|
|
|
|
$results[] = $this->checkOrphanedGroups($tenant, $policyItems, $groupMapping);
|
|
$results[] = $this->checkMetadataOnlySnapshots($policyItems);
|
|
$results[] = $this->checkPreviewOnlyPolicies($policyItems);
|
|
$results[] = $this->checkMissingPolicies($tenant, $policyItems);
|
|
$results[] = $this->checkStalePolicies($tenant, $policyItems);
|
|
$results[] = $this->checkMissingScopeTagsInScope($items, $policyItems, $selectedItemIds !== null);
|
|
|
|
$results = array_values(array_filter($results));
|
|
|
|
$summary = [
|
|
'blocking' => 0,
|
|
'warning' => 0,
|
|
'safe' => 0,
|
|
'has_blockers' => false,
|
|
];
|
|
|
|
foreach ($results as $result) {
|
|
$severity = $result['severity'] ?? 'safe';
|
|
|
|
if (! in_array($severity, ['blocking', 'warning', 'safe'], true)) {
|
|
$severity = 'safe';
|
|
}
|
|
|
|
$summary[$severity]++;
|
|
}
|
|
|
|
$summary['has_blockers'] = $summary['blocking'] > 0;
|
|
|
|
return [
|
|
'summary' => $summary,
|
|
'results' => $results,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int>|null $selectedItemIds
|
|
* @return Collection<int, BackupItem>
|
|
*/
|
|
private function loadItems(BackupSet $backupSet, ?array $selectedItemIds): Collection
|
|
{
|
|
$query = $backupSet->items()->getQuery();
|
|
|
|
if ($selectedItemIds !== null) {
|
|
$query->whereIn('id', $selectedItemIds);
|
|
}
|
|
|
|
return $query->orderBy('id')->get();
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, BackupItem> $policyItems
|
|
* @param array<string, string|null> $groupMapping
|
|
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
|
|
*/
|
|
private function checkOrphanedGroups(Tenant $tenant, Collection $policyItems, array $groupMapping): ?array
|
|
{
|
|
[$groupIds, $sourceNames] = $this->extractGroupIds($policyItems);
|
|
|
|
if ($groupIds === []) {
|
|
return [
|
|
'code' => 'assignment_groups',
|
|
'severity' => 'safe',
|
|
'title' => 'Assignments',
|
|
'message' => 'No group-based assignments detected.',
|
|
'meta' => [
|
|
'group_count' => 0,
|
|
],
|
|
];
|
|
}
|
|
|
|
$graphOptions = $tenant->graphOptions();
|
|
$tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey();
|
|
$resolved = $this->groupResolver->resolveGroupIds($groupIds, $tenantIdentifier, $graphOptions);
|
|
|
|
$orphaned = [];
|
|
|
|
foreach ($groupIds as $groupId) {
|
|
$group = $resolved[$groupId] ?? null;
|
|
|
|
if (! is_array($group) || ! ($group['orphaned'] ?? false)) {
|
|
continue;
|
|
}
|
|
|
|
$orphaned[] = [
|
|
'id' => $groupId,
|
|
'label' => $this->formatGroupLabel($sourceNames[$groupId] ?? null, $groupId),
|
|
];
|
|
}
|
|
|
|
if ($orphaned === []) {
|
|
return [
|
|
'code' => 'assignment_groups',
|
|
'severity' => 'safe',
|
|
'title' => 'Assignments',
|
|
'message' => sprintf('%d group assignment targets resolved.', count($groupIds)),
|
|
'meta' => [
|
|
'group_count' => count($groupIds),
|
|
'orphaned_count' => 0,
|
|
],
|
|
];
|
|
}
|
|
|
|
$unmapped = [];
|
|
$mapped = [];
|
|
$skipped = [];
|
|
|
|
foreach ($orphaned as $group) {
|
|
$groupId = $group['id'];
|
|
$mapping = $groupMapping[$groupId] ?? null;
|
|
|
|
if (! is_string($mapping) || $mapping === '') {
|
|
$unmapped[] = $group;
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($mapping === 'SKIP') {
|
|
$skipped[] = $group;
|
|
|
|
continue;
|
|
}
|
|
|
|
$mapped[] = $group + [
|
|
'mapped_to' => $mapping,
|
|
];
|
|
}
|
|
|
|
$severity = $unmapped !== [] ? 'blocking' : 'warning';
|
|
|
|
$message = $unmapped !== []
|
|
? sprintf('%d group assignment targets are missing in the tenant and require mapping (or skip).', count($unmapped))
|
|
: sprintf('%d group assignment targets are missing in the tenant (mapped/skipped).', count($orphaned));
|
|
|
|
return [
|
|
'code' => 'assignment_groups',
|
|
'severity' => $severity,
|
|
'title' => 'Assignments',
|
|
'message' => $message,
|
|
'meta' => [
|
|
'group_count' => count($groupIds),
|
|
'orphaned_count' => count($orphaned),
|
|
'unmapped' => $unmapped,
|
|
'mapped' => $mapped,
|
|
'skipped' => $skipped,
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, BackupItem> $policyItems
|
|
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
|
|
*/
|
|
private function checkPreviewOnlyPolicies(Collection $policyItems): ?array
|
|
{
|
|
$byType = [];
|
|
|
|
foreach ($policyItems as $item) {
|
|
$restoreMode = $this->resolveRestoreMode($item->policy_type);
|
|
|
|
if ($restoreMode !== 'preview-only') {
|
|
continue;
|
|
}
|
|
|
|
$label = $this->resolveTypeLabel($item->policy_type);
|
|
$byType[$label] ??= 0;
|
|
$byType[$label]++;
|
|
}
|
|
|
|
if ($byType === []) {
|
|
return [
|
|
'code' => 'preview_only',
|
|
'severity' => 'safe',
|
|
'title' => 'Preview-only types',
|
|
'message' => 'No preview-only policy types detected.',
|
|
'meta' => [
|
|
'count' => 0,
|
|
],
|
|
];
|
|
}
|
|
|
|
return [
|
|
'code' => 'preview_only',
|
|
'severity' => 'warning',
|
|
'title' => 'Preview-only types',
|
|
'message' => 'Some selected items are preview-only and will never execute.',
|
|
'meta' => [
|
|
'count' => array_sum($byType),
|
|
'types' => $byType,
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Detect snapshots that were captured as metadata-only.
|
|
*
|
|
* These snapshots cannot be safely restored because they do not contain the
|
|
* complete settings payload.
|
|
*
|
|
* @param Collection<int, BackupItem> $policyItems
|
|
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
|
|
*/
|
|
private function checkMetadataOnlySnapshots(Collection $policyItems): ?array
|
|
{
|
|
$affected = [];
|
|
$hasRestoreEnabled = false;
|
|
|
|
foreach ($policyItems as $item) {
|
|
if (! $this->isMetadataOnlySnapshot($item)) {
|
|
continue;
|
|
}
|
|
|
|
$restoreMode = $this->resolveRestoreMode($item->policy_type);
|
|
if ($restoreMode !== 'preview-only') {
|
|
$hasRestoreEnabled = true;
|
|
}
|
|
|
|
$affected[] = [
|
|
'backup_item_id' => $item->id,
|
|
'policy_identifier' => $item->policy_identifier,
|
|
'policy_type' => $item->policy_type,
|
|
'label' => $item->resolvedDisplayName(),
|
|
'restore_mode' => $restoreMode,
|
|
];
|
|
}
|
|
|
|
if ($affected === []) {
|
|
return [
|
|
'code' => 'metadata_only',
|
|
'severity' => 'safe',
|
|
'title' => 'Snapshot completeness',
|
|
'message' => 'No metadata-only snapshots detected.',
|
|
'meta' => [
|
|
'count' => 0,
|
|
],
|
|
];
|
|
}
|
|
|
|
$severity = $hasRestoreEnabled ? 'blocking' : 'warning';
|
|
$message = $hasRestoreEnabled
|
|
? 'Some selected items were captured as metadata-only. Restore cannot execute until Graph works again.'
|
|
: 'Some selected items were captured as metadata-only. Execution is preview-only, but payload completeness is limited.';
|
|
|
|
return [
|
|
'code' => 'metadata_only',
|
|
'severity' => $severity,
|
|
'title' => 'Snapshot completeness',
|
|
'message' => $message,
|
|
'meta' => [
|
|
'count' => count($affected),
|
|
'items' => $this->truncateList($affected, 10),
|
|
],
|
|
];
|
|
}
|
|
|
|
private function isMetadataOnlySnapshot(BackupItem $item): bool
|
|
{
|
|
$metadata = is_array($item->metadata) ? $item->metadata : [];
|
|
|
|
$source = $metadata['source'] ?? null;
|
|
$snapshotSource = $metadata['snapshot_source'] ?? null;
|
|
|
|
if ($source === 'metadata_only' || $snapshotSource === 'metadata_only') {
|
|
return true;
|
|
}
|
|
|
|
$warnings = $metadata['warnings'] ?? null;
|
|
if (is_array($warnings)) {
|
|
foreach ($warnings as $warning) {
|
|
if (is_string($warning) && Str::contains(Str::lower($warning), 'metadata only')) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, BackupItem> $policyItems
|
|
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
|
|
*/
|
|
private function checkMissingPolicies(Tenant $tenant, Collection $policyItems): ?array
|
|
{
|
|
$pairs = [];
|
|
|
|
foreach ($policyItems as $item) {
|
|
$identifier = $item->policy_identifier;
|
|
$type = $item->policy_type;
|
|
|
|
if (! is_string($identifier) || $identifier === '' || ! is_string($type) || $type === '') {
|
|
continue;
|
|
}
|
|
|
|
$pairs[] = [
|
|
'identifier' => $identifier,
|
|
'type' => $type,
|
|
'label' => $item->resolvedDisplayName(),
|
|
];
|
|
}
|
|
|
|
if ($pairs === []) {
|
|
return [
|
|
'code' => 'missing_policies',
|
|
'severity' => 'safe',
|
|
'title' => 'Target policies',
|
|
'message' => 'No policy identifiers available to verify.',
|
|
'meta' => [
|
|
'missing_count' => 0,
|
|
],
|
|
];
|
|
}
|
|
|
|
$identifiers = array_values(array_unique(array_column($pairs, 'identifier')));
|
|
$types = array_values(array_unique(array_column($pairs, 'type')));
|
|
|
|
$existing = Policy::query()
|
|
->where('tenant_id', $tenant->id)
|
|
->whereIn('external_id', $identifiers)
|
|
->whereIn('policy_type', $types)
|
|
->get(['id', 'external_id', 'policy_type'])
|
|
->mapWithKeys(fn (Policy $policy) => [$this->policyKey($policy->policy_type, $policy->external_id) => $policy->id])
|
|
->all();
|
|
|
|
$missing = [];
|
|
|
|
foreach ($pairs as $pair) {
|
|
$key = $this->policyKey($pair['type'], $pair['identifier']);
|
|
|
|
if (array_key_exists($key, $existing)) {
|
|
continue;
|
|
}
|
|
|
|
$missing[] = [
|
|
'type' => $pair['type'],
|
|
'identifier' => $pair['identifier'],
|
|
'label' => $pair['label'],
|
|
];
|
|
}
|
|
|
|
$missing = array_values(collect($missing)->unique(fn (array $row) => $this->policyKey($row['type'], $row['identifier']))->all());
|
|
|
|
if ($missing === []) {
|
|
return [
|
|
'code' => 'missing_policies',
|
|
'severity' => 'safe',
|
|
'title' => 'Target policies',
|
|
'message' => 'All policies exist in the tenant (restore will update).',
|
|
'meta' => [
|
|
'missing_count' => 0,
|
|
],
|
|
];
|
|
}
|
|
|
|
return [
|
|
'code' => 'missing_policies',
|
|
'severity' => 'warning',
|
|
'title' => 'Target policies',
|
|
'message' => sprintf('%d policies do not exist in the tenant and will be created.', count($missing)),
|
|
'meta' => [
|
|
'missing_count' => count($missing),
|
|
'missing' => $this->truncateList($missing, 10),
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, BackupItem> $policyItems
|
|
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
|
|
*/
|
|
private function checkStalePolicies(Tenant $tenant, Collection $policyItems): ?array
|
|
{
|
|
$itemsByPolicyId = [];
|
|
|
|
foreach ($policyItems as $item) {
|
|
if (! $item->policy_id) {
|
|
continue;
|
|
}
|
|
|
|
$capturedAt = $item->captured_at;
|
|
|
|
if (! $capturedAt) {
|
|
continue;
|
|
}
|
|
|
|
$itemsByPolicyId[$item->policy_id][] = [
|
|
'backup_item_id' => $item->id,
|
|
'captured_at' => $capturedAt,
|
|
'label' => $item->resolvedDisplayName(),
|
|
];
|
|
}
|
|
|
|
if ($itemsByPolicyId === []) {
|
|
return [
|
|
'code' => 'stale_policies',
|
|
'severity' => 'safe',
|
|
'title' => 'Staleness',
|
|
'message' => 'No captured timestamps available to evaluate staleness.',
|
|
'meta' => [
|
|
'stale_count' => 0,
|
|
],
|
|
];
|
|
}
|
|
|
|
$latestVersions = PolicyVersion::query()
|
|
->where('tenant_id', $tenant->id)
|
|
->whereIn('policy_id', array_keys($itemsByPolicyId))
|
|
->selectRaw('policy_id, max(captured_at) as latest_captured_at')
|
|
->groupBy('policy_id')
|
|
->get()
|
|
->mapWithKeys(function (PolicyVersion $version) {
|
|
$latestCapturedAt = $version->getAttribute('latest_captured_at');
|
|
|
|
if (is_string($latestCapturedAt) && $latestCapturedAt !== '') {
|
|
$latestCapturedAt = CarbonImmutable::parse($latestCapturedAt);
|
|
} else {
|
|
$latestCapturedAt = null;
|
|
}
|
|
|
|
return [
|
|
(int) $version->policy_id => $latestCapturedAt,
|
|
];
|
|
})
|
|
->all();
|
|
|
|
$stale = [];
|
|
|
|
foreach ($itemsByPolicyId as $policyId => $policyItems) {
|
|
$latestCapturedAt = $latestVersions[(int) $policyId] ?? null;
|
|
|
|
if (! $latestCapturedAt) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($policyItems as $policyItem) {
|
|
if ($latestCapturedAt->greaterThan($policyItem['captured_at'])) {
|
|
$stale[] = [
|
|
'backup_item_id' => $policyItem['backup_item_id'],
|
|
'label' => $policyItem['label'],
|
|
'snapshot_captured_at' => $policyItem['captured_at']->toIso8601String(),
|
|
'latest_captured_at' => $latestCapturedAt->toIso8601String(),
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($stale === []) {
|
|
return [
|
|
'code' => 'stale_policies',
|
|
'severity' => 'safe',
|
|
'title' => 'Staleness',
|
|
'message' => 'No newer versions detected since the snapshot.',
|
|
'meta' => [
|
|
'stale_count' => 0,
|
|
],
|
|
];
|
|
}
|
|
|
|
return [
|
|
'code' => 'stale_policies',
|
|
'severity' => 'warning',
|
|
'title' => 'Staleness',
|
|
'message' => sprintf('%d policies have newer versions in the tenant than this snapshot.', count($stale)),
|
|
'meta' => [
|
|
'stale_count' => count($stale),
|
|
'stale' => $this->truncateList($stale, 10),
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, BackupItem> $items
|
|
* @param Collection<int, BackupItem> $policyItems
|
|
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
|
|
*/
|
|
private function checkMissingScopeTagsInScope(Collection $items, Collection $policyItems, bool $isSelectedScope): ?array
|
|
{
|
|
if (! $isSelectedScope) {
|
|
return [
|
|
'code' => 'scope_tags_in_scope',
|
|
'severity' => 'safe',
|
|
'title' => 'Scope tags',
|
|
'message' => 'Scope includes all items; foundations are available if present in the backup set.',
|
|
'meta' => [
|
|
'missing_scope_tags' => false,
|
|
],
|
|
];
|
|
}
|
|
|
|
$selectedScopeTagCount = $items->where('policy_type', 'roleScopeTag')->count();
|
|
|
|
$scopeTagIds = [];
|
|
|
|
foreach ($policyItems as $item) {
|
|
$ids = $item->scope_tag_ids;
|
|
|
|
if (! is_array($ids)) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($ids as $id) {
|
|
if (! is_string($id) || $id === '' || $id === '0') {
|
|
continue;
|
|
}
|
|
|
|
$scopeTagIds[] = $id;
|
|
}
|
|
}
|
|
|
|
$scopeTagIds = array_values(array_unique($scopeTagIds));
|
|
|
|
if ($scopeTagIds === [] || $selectedScopeTagCount > 0) {
|
|
return [
|
|
'code' => 'scope_tags_in_scope',
|
|
'severity' => 'safe',
|
|
'title' => 'Scope tags',
|
|
'message' => 'Scope tags look OK for the selected items.',
|
|
'meta' => [
|
|
'missing_scope_tags' => false,
|
|
'referenced_scope_tags' => count($scopeTagIds),
|
|
'selected_scope_tag_items' => $selectedScopeTagCount,
|
|
],
|
|
];
|
|
}
|
|
|
|
return [
|
|
'code' => 'scope_tags_in_scope',
|
|
'severity' => 'warning',
|
|
'title' => 'Scope tags',
|
|
'message' => 'Policies reference scope tags, but scope tags are not included in the selected restore scope.',
|
|
'meta' => [
|
|
'missing_scope_tags' => true,
|
|
'referenced_scope_tags' => count($scopeTagIds),
|
|
'selected_scope_tag_items' => 0,
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, BackupItem> $policyItems
|
|
* @return array{0: array<int, string>, 1: array<string, string>}
|
|
*/
|
|
private function extractGroupIds(Collection $policyItems): array
|
|
{
|
|
$groupIds = [];
|
|
$sourceNames = [];
|
|
|
|
foreach ($policyItems as $item) {
|
|
if (! is_array($item->assignments) || $item->assignments === []) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($item->assignments as $assignment) {
|
|
if (! is_array($assignment)) {
|
|
continue;
|
|
}
|
|
|
|
$target = $assignment['target'] ?? [];
|
|
$odataType = $target['@odata.type'] ?? '';
|
|
|
|
if (! in_array($odataType, [
|
|
'#microsoft.graph.groupAssignmentTarget',
|
|
'#microsoft.graph.exclusionGroupAssignmentTarget',
|
|
], true)) {
|
|
continue;
|
|
}
|
|
|
|
$groupId = $target['groupId'] ?? null;
|
|
|
|
if (! is_string($groupId) || $groupId === '') {
|
|
continue;
|
|
}
|
|
|
|
$groupIds[] = $groupId;
|
|
|
|
$displayName = $target['group_display_name'] ?? null;
|
|
|
|
if (is_string($displayName) && $displayName !== '') {
|
|
$sourceNames[$groupId] = $displayName;
|
|
}
|
|
}
|
|
}
|
|
|
|
$groupIds = array_values(array_unique($groupIds));
|
|
|
|
return [$groupIds, $sourceNames];
|
|
}
|
|
|
|
private function formatGroupLabel(?string $name, string $id): string
|
|
{
|
|
$parts = [];
|
|
|
|
if (is_string($name) && $name !== '') {
|
|
$parts[] = $name;
|
|
}
|
|
|
|
$parts[] = Str::limit($id, 24, '...');
|
|
|
|
return implode(' • ', $parts);
|
|
}
|
|
|
|
private function policyKey(string $type, string $identifier): string
|
|
{
|
|
return $type.'|'.$identifier;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function resolveTypeMeta(?string $type): array
|
|
{
|
|
if (! is_string($type) || $type === '') {
|
|
return [];
|
|
}
|
|
|
|
$types = array_merge(
|
|
config('tenantpilot.supported_policy_types', []),
|
|
config('tenantpilot.foundation_types', [])
|
|
);
|
|
|
|
foreach ($types as $typeConfig) {
|
|
if (($typeConfig['type'] ?? null) === $type) {
|
|
return is_array($typeConfig) ? $typeConfig : [];
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
private function resolveRestoreMode(?string $policyType): string
|
|
{
|
|
$meta = $this->resolveTypeMeta($policyType);
|
|
|
|
return (string) ($meta['restore'] ?? 'enabled');
|
|
}
|
|
|
|
private function resolveTypeLabel(?string $policyType): string
|
|
{
|
|
$meta = $this->resolveTypeMeta($policyType);
|
|
|
|
return (string) ($meta['label'] ?? $policyType ?? 'Unknown');
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $items
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
private function truncateList(array $items, int $limit): array
|
|
{
|
|
if (count($items) <= $limit) {
|
|
return $items;
|
|
}
|
|
|
|
return array_slice($items, 0, $limit);
|
|
}
|
|
}
|