feat/004-assignments-scope-tags #4
@ -21,6 +21,8 @@ ## TenantPilot setup
|
|||||||
- `GRAPH_CLIENT_SECRET`
|
- `GRAPH_CLIENT_SECRET`
|
||||||
- `GRAPH_SCOPE` (default `https://graph.microsoft.com/.default`)
|
- `GRAPH_SCOPE` (default `https://graph.microsoft.com/.default`)
|
||||||
- Without these, the `NullGraphClient` runs in dry mode (no Graph calls).
|
- Without these, the `NullGraphClient` runs in dry mode (no Graph calls).
|
||||||
|
- **Required API Permissions**: See [docs/PERMISSIONS.md](docs/PERMISSIONS.md) for complete list
|
||||||
|
- **Missing permissions?** Scope tags will show as "Unknown (ID: X)" - add `DeviceManagementRBAC.Read.All`
|
||||||
- Deployment (Dokploy, staging → production):
|
- Deployment (Dokploy, staging → production):
|
||||||
- Containerized deploy; ensure Postgres + Redis are provisioned (see `docker-compose.yml` for local baseline).
|
- Containerized deploy; ensure Postgres + Redis are provisioned (see `docker-compose.yml` for local baseline).
|
||||||
- Run migrations on staging first, validate backup/restore flows, then promote to production.
|
- Run migrations on staging first, validate backup/restore flows, then promote to production.
|
||||||
|
|||||||
@ -203,6 +203,11 @@ public static function infolist(Schema $schema): Schema
|
|||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
|
->modifyQueryUsing(function (Builder $query) {
|
||||||
|
// Quick-Workaround: Hide policies not synced in last 7 days
|
||||||
|
// Full solution in Feature 005: Policy Lifecycle Management (soft delete)
|
||||||
|
$query->where('last_synced_at', '>', now()->subDays(7));
|
||||||
|
})
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('display_name')
|
Tables\Columns\TextColumn::make('display_name')
|
||||||
->label('Policy')
|
->label('Policy')
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\BackupItem;
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\Tenant;
|
||||||
use App\Services\Graph\AssignmentFetcher;
|
use App\Services\Graph\AssignmentFetcher;
|
||||||
use App\Services\Graph\GroupResolver;
|
use App\Services\Graph\GroupResolver;
|
||||||
use App\Services\Graph\ScopeTagResolver;
|
use App\Services\Graph\ScopeTagResolver;
|
||||||
@ -20,7 +21,7 @@ public function __construct(
|
|||||||
* Enrich a backup item with assignments and scope tag metadata.
|
* Enrich a backup item with assignments and scope tag metadata.
|
||||||
*
|
*
|
||||||
* @param BackupItem $backupItem The backup item to enrich
|
* @param BackupItem $backupItem The backup item to enrich
|
||||||
* @param string $tenantId Tenant ID for Graph API calls
|
* @param Tenant $tenant Tenant model with credentials
|
||||||
* @param string $policyId Policy ID (external_id from Graph)
|
* @param string $policyId Policy ID (external_id from Graph)
|
||||||
* @param array $policyPayload Full policy payload from Graph
|
* @param array $policyPayload Full policy payload from Graph
|
||||||
* @param bool $includeAssignments Whether to fetch and include assignments
|
* @param bool $includeAssignments Whether to fetch and include assignments
|
||||||
@ -28,14 +29,14 @@ public function __construct(
|
|||||||
*/
|
*/
|
||||||
public function enrichWithAssignments(
|
public function enrichWithAssignments(
|
||||||
BackupItem $backupItem,
|
BackupItem $backupItem,
|
||||||
string $tenantId,
|
Tenant $tenant,
|
||||||
string $policyId,
|
string $policyId,
|
||||||
array $policyPayload,
|
array $policyPayload,
|
||||||
bool $includeAssignments = false
|
bool $includeAssignments = false
|
||||||
): BackupItem {
|
): BackupItem {
|
||||||
// Extract scope tags from payload (always available in policy)
|
// Extract scope tags from payload (always available in policy)
|
||||||
$scopeTagIds = $policyPayload['roleScopeTagIds'] ?? ['0'];
|
$scopeTagIds = $policyPayload['roleScopeTagIds'] ?? ['0'];
|
||||||
$scopeTagNames = $this->resolveScopeTagNames($scopeTagIds);
|
$scopeTagNames = $this->resolveScopeTagNames($scopeTagIds, $tenant);
|
||||||
|
|
||||||
$metadata = $backupItem->metadata ?? [];
|
$metadata = $backupItem->metadata ?? [];
|
||||||
$metadata['scope_tag_ids'] = $scopeTagIds;
|
$metadata['scope_tag_ids'] = $scopeTagIds;
|
||||||
@ -53,6 +54,7 @@ public function enrichWithAssignments(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch assignments from Graph API
|
// Fetch assignments from Graph API
|
||||||
|
$tenantId = $tenant->external_id ?? $tenant->tenant_id;
|
||||||
$assignments = $this->assignmentFetcher->fetch($tenantId, $policyId);
|
$assignments = $this->assignmentFetcher->fetch($tenantId, $policyId);
|
||||||
|
|
||||||
if (empty($assignments)) {
|
if (empty($assignments)) {
|
||||||
@ -108,9 +110,9 @@ public function enrichWithAssignments(
|
|||||||
/**
|
/**
|
||||||
* Resolve scope tag IDs to display names.
|
* Resolve scope tag IDs to display names.
|
||||||
*/
|
*/
|
||||||
private function resolveScopeTagNames(array $scopeTagIds): array
|
private function resolveScopeTagNames(array $scopeTagIds, Tenant $tenant): array
|
||||||
{
|
{
|
||||||
$scopeTags = $this->scopeTagResolver->resolve($scopeTagIds);
|
$scopeTags = $this->scopeTagResolver->resolve($scopeTagIds, $tenant);
|
||||||
|
|
||||||
$names = [];
|
$names = [];
|
||||||
foreach ($scopeTagIds as $id) {
|
foreach ($scopeTagIds as $id) {
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Services\Graph;
|
namespace App\Services\Graph;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
class ScopeTagResolver
|
class ScopeTagResolver
|
||||||
@ -18,16 +19,17 @@ public function __construct(
|
|||||||
* Filters to requested IDs in memory.
|
* Filters to requested IDs in memory.
|
||||||
*
|
*
|
||||||
* @param array $scopeTagIds Array of scope tag IDs to resolve
|
* @param array $scopeTagIds Array of scope tag IDs to resolve
|
||||||
|
* @param Tenant|null $tenant Optional tenant model with credentials
|
||||||
* @return array Array of scope tag objects with id and displayName
|
* @return array Array of scope tag objects with id and displayName
|
||||||
*/
|
*/
|
||||||
public function resolve(array $scopeTagIds): array
|
public function resolve(array $scopeTagIds, ?Tenant $tenant = null): array
|
||||||
{
|
{
|
||||||
if (empty($scopeTagIds)) {
|
if (empty($scopeTagIds)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all scope tags (cached)
|
// Fetch all scope tags (cached)
|
||||||
$allScopeTags = $this->fetchAllScopeTags();
|
$allScopeTags = $this->fetchAllScopeTags($tenant);
|
||||||
|
|
||||||
// Filter to requested IDs
|
// Filter to requested IDs
|
||||||
return array_filter($allScopeTags, function ($scopeTag) use ($scopeTagIds) {
|
return array_filter($allScopeTags, function ($scopeTag) use ($scopeTagIds) {
|
||||||
@ -38,27 +40,46 @@ public function resolve(array $scopeTagIds): array
|
|||||||
/**
|
/**
|
||||||
* Fetch all scope tags from Graph API (cached for 1 hour).
|
* Fetch all scope tags from Graph API (cached for 1 hour).
|
||||||
*/
|
*/
|
||||||
private function fetchAllScopeTags(): array
|
private function fetchAllScopeTags(?Tenant $tenant = null): array
|
||||||
{
|
{
|
||||||
return Cache::remember('scope_tags:all', 3600, function () {
|
$cacheKey = $tenant ? "scope_tags:tenant:{$tenant->id}" : 'scope_tags:all';
|
||||||
|
|
||||||
|
return Cache::remember($cacheKey, 3600, function () use ($tenant) {
|
||||||
try {
|
try {
|
||||||
$response = $this->graphClient->get(
|
$options = ['query' => ['$select' => 'id,displayName']];
|
||||||
|
|
||||||
|
// Add tenant credentials if provided
|
||||||
|
if ($tenant) {
|
||||||
|
$options['tenant'] = $tenant->external_id ?? $tenant->tenant_id;
|
||||||
|
$options['client_id'] = $tenant->app_client_id;
|
||||||
|
$options['client_secret'] = $tenant->app_client_secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
$graphResponse = $this->graphClient->request(
|
||||||
|
'GET',
|
||||||
'/deviceManagement/roleScopeTags',
|
'/deviceManagement/roleScopeTags',
|
||||||
null,
|
$options
|
||||||
['$select' => 'id,displayName']
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$scopeTags = $response['value'] ?? [];
|
$scopeTags = $graphResponse->data['value'] ?? [];
|
||||||
|
|
||||||
$this->logger->logDebug('Fetched scope tags', [
|
// Check for 403 Forbidden (missing permissions)
|
||||||
'count' => count($scopeTags),
|
if (! $graphResponse->success && $graphResponse->status === 403) {
|
||||||
]);
|
\Log::warning('Scope tag fetch failed: Missing permissions', [
|
||||||
|
'tenant_id' => $tenant?->id,
|
||||||
|
'status' => 403,
|
||||||
|
'required_permissions' => ['DeviceManagementRBAC.Read.All', 'DeviceManagementRBAC.ReadWrite.All'],
|
||||||
|
'message' => 'App registration needs DeviceManagementRBAC permissions to read scope tags',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success - return scope tags
|
||||||
return $scopeTags;
|
return $scopeTags;
|
||||||
} catch (GraphException $e) {
|
} catch (\Exception $e) {
|
||||||
$this->logger->logWarning('Failed to fetch scope tags', [
|
// Fail soft - return empty array on any error
|
||||||
|
\Log::warning('Scope tag fetch exception', [
|
||||||
|
'tenant_id' => $tenant?->id,
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
'context' => $e->context,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@ -280,7 +280,7 @@ private function snapshotPolicy(
|
|||||||
if ($policy->policy_type === 'settingsCatalogPolicy') {
|
if ($policy->policy_type === 'settingsCatalogPolicy') {
|
||||||
$backupItem = $this->assignmentBackupService->enrichWithAssignments(
|
$backupItem = $this->assignmentBackupService->enrichWithAssignments(
|
||||||
backupItem: $backupItem,
|
backupItem: $backupItem,
|
||||||
tenantId: $tenant->external_id,
|
tenant: $tenant,
|
||||||
policyId: $policy->external_id,
|
policyId: $policy->external_id,
|
||||||
policyPayload: $payload,
|
policyPayload: $payload,
|
||||||
includeAssignments: $includeAssignments
|
includeAssignments: $includeAssignments
|
||||||
|
|||||||
154
docs/PERMISSIONS.md
Normal file
154
docs/PERMISSIONS.md
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
# Microsoft Graph API Permissions
|
||||||
|
|
||||||
|
This document lists all required Microsoft Graph API permissions for TenantPilot to function correctly.
|
||||||
|
|
||||||
|
## Required Permissions
|
||||||
|
|
||||||
|
The Azure AD / Entra ID **App Registration** used by TenantPilot requires the following **Application Permissions** (not Delegated):
|
||||||
|
|
||||||
|
### Core Policy Management (Required)
|
||||||
|
- `DeviceManagementConfiguration.Read.All` - Read Intune device configuration policies
|
||||||
|
- `DeviceManagementConfiguration.ReadWrite.All` - Write/restore Intune policies
|
||||||
|
- `DeviceManagementApps.Read.All` - Read app configuration policies
|
||||||
|
- `DeviceManagementApps.ReadWrite.All` - Write app policies
|
||||||
|
|
||||||
|
### Scope Tags (Feature 004 - Required for Phase 3)
|
||||||
|
- **`DeviceManagementRBAC.Read.All`** - Read scope tags and RBAC settings
|
||||||
|
- **Purpose**: Resolve scope tag IDs to display names (e.g., "0" → "Default")
|
||||||
|
- **Missing**: Backup items will show "Unknown (ID: 0)" instead of scope tag names
|
||||||
|
- **Impact**: Metadata display only - backups still work without this permission
|
||||||
|
|
||||||
|
### Group Resolution (Feature 004 - Required for Phase 2)
|
||||||
|
- `Group.Read.All` - Resolve group IDs to names for assignments
|
||||||
|
- `Directory.Read.All` - Batch resolve directory objects (groups, users, devices)
|
||||||
|
|
||||||
|
## How to Add Permissions
|
||||||
|
|
||||||
|
### Azure Portal (Entra ID)
|
||||||
|
|
||||||
|
1. Go to **Azure Portal** → **Entra ID** (Azure Active Directory)
|
||||||
|
2. Navigate to **App registrations** → Select your TenantPilot app
|
||||||
|
3. Click **API permissions** in the left menu
|
||||||
|
4. Click **+ Add a permission**
|
||||||
|
5. Select **Microsoft Graph** → **Application permissions**
|
||||||
|
6. Search for and select the required permissions:
|
||||||
|
- `DeviceManagementRBAC.Read.All`
|
||||||
|
- (Add others as needed)
|
||||||
|
7. Click **Add permissions**
|
||||||
|
8. **IMPORTANT**: Click **Grant admin consent for [Your Organization]**
|
||||||
|
- ⚠️ Without admin consent, the permissions won't be active!
|
||||||
|
|
||||||
|
### PowerShell (Alternative)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Connect to Microsoft Graph
|
||||||
|
Connect-MgGraph -Scopes "Application.ReadWrite.All"
|
||||||
|
|
||||||
|
# Get your app registration
|
||||||
|
$appId = "YOUR-APP-CLIENT-ID"
|
||||||
|
$app = Get-MgApplication -Filter "appId eq '$appId'"
|
||||||
|
|
||||||
|
# Add DeviceManagementRBAC.Read.All permission
|
||||||
|
$graphServicePrincipal = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'"
|
||||||
|
$rbacPermission = $graphServicePrincipal.AppRoles | Where-Object {$_.Value -eq "DeviceManagementRBAC.Read.All"}
|
||||||
|
|
||||||
|
$requiredResourceAccess = @{
|
||||||
|
ResourceAppId = "00000003-0000-0000-c000-000000000000"
|
||||||
|
ResourceAccess = @(
|
||||||
|
@{
|
||||||
|
Id = $rbacPermission.Id
|
||||||
|
Type = "Role"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Update-MgApplication -ApplicationId $app.Id -RequiredResourceAccess $requiredResourceAccess
|
||||||
|
|
||||||
|
# Grant admin consent
|
||||||
|
# (Must be done manually or via Graph API with RoleManagement.ReadWrite.Directory scope)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After adding permissions and granting admin consent:
|
||||||
|
|
||||||
|
1. Go to **App registrations** → Your app → **API permissions**
|
||||||
|
2. Verify status shows **Granted for [Your Organization]** with a green checkmark ✅
|
||||||
|
3. Clear cache in TenantPilot:
|
||||||
|
```bash
|
||||||
|
php artisan cache:clear
|
||||||
|
```
|
||||||
|
4. Test scope tag resolution:
|
||||||
|
```bash
|
||||||
|
php artisan tinker
|
||||||
|
>>> use App\Services\Graph\ScopeTagResolver;
|
||||||
|
>>> use App\Models\Tenant;
|
||||||
|
>>> $tenant = Tenant::first();
|
||||||
|
>>> $resolver = app(ScopeTagResolver::class);
|
||||||
|
>>> $tags = $resolver->resolve(['0'], $tenant);
|
||||||
|
>>> dd($tags);
|
||||||
|
```
|
||||||
|
Expected output:
|
||||||
|
```php
|
||||||
|
[
|
||||||
|
[
|
||||||
|
"id" => "0",
|
||||||
|
"displayName" => "Default"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Error: "Application is not authorized to perform this operation"
|
||||||
|
|
||||||
|
**Symptoms:**
|
||||||
|
- Backup items show "Unknown (ID: 0)" for scope tags
|
||||||
|
- Logs contain: `Application must have one of the following scopes: DeviceManagementRBAC.Read.All`
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Add `DeviceManagementRBAC.Read.All` permission (see above)
|
||||||
|
2. **Grant admin consent** (critical step!)
|
||||||
|
3. Wait 5-10 minutes for Azure to propagate permissions
|
||||||
|
4. Clear cache: `php artisan cache:clear`
|
||||||
|
5. Test again
|
||||||
|
|
||||||
|
### Error: "Insufficient privileges to complete the operation"
|
||||||
|
|
||||||
|
**Cause:** The user account used to grant admin consent doesn't have sufficient permissions.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Use an account with **Global Administrator** or **Privileged Role Administrator** role
|
||||||
|
- Or have the IT admin grant consent for the organization
|
||||||
|
|
||||||
|
### Permissions showing but still getting 403
|
||||||
|
|
||||||
|
**Possible causes:**
|
||||||
|
1. Admin consent not granted (click the button!)
|
||||||
|
2. Permissions not yet propagated (wait 5-10 minutes)
|
||||||
|
3. Wrong tenant (check tenant ID in app config)
|
||||||
|
4. Cached token needs refresh (clear cache + restart)
|
||||||
|
|
||||||
|
## Feature Impact Matrix
|
||||||
|
|
||||||
|
| Feature | Required Permissions | Without Permission | Impact Level |
|
||||||
|
|---------|---------------------|-------------------|--------------|
|
||||||
|
| Basic Policy Backup | `DeviceManagementConfiguration.Read.All` | Cannot backup | 🔴 Critical |
|
||||||
|
| Policy Restore | `DeviceManagementConfiguration.ReadWrite.All` | Cannot restore | 🔴 Critical |
|
||||||
|
| Scope Tag Names (004) | `DeviceManagementRBAC.Read.All` | Shows "Unknown (ID: X)" | 🟡 Medium |
|
||||||
|
| Assignment Names (004) | `Group.Read.All` + `Directory.Read.All` | Shows group IDs only | 🟡 Medium |
|
||||||
|
| Group Mapping (004) | `Group.Read.All` | Manual ID mapping required | 🟡 Medium |
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- All permissions are **Application Permissions** (app-level, not user-level)
|
||||||
|
- Requires **admin consent** from Global Administrator
|
||||||
|
- Use **least privilege principle**: Only add permissions for features you use
|
||||||
|
- Consider creating separate app registrations for different environments (dev/staging/prod)
|
||||||
|
- Rotate client secrets regularly (recommended: every 6 months)
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Microsoft Graph API Permissions](https://learn.microsoft.com/en-us/graph/permissions-reference)
|
||||||
|
- [Intune Graph API Overview](https://learn.microsoft.com/en-us/graph/api/resources/intune-graph-overview)
|
||||||
|
- [App Registration Best Practices](https://learn.microsoft.com/en-us/azure/active-directory/develop/security-best-practices-for-app-registration)
|
||||||
228
specs/005-policy-lifecycle/spec.md
Normal file
228
specs/005-policy-lifecycle/spec.md
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
# Feature 005: Policy Lifecycle Management
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Implement proper lifecycle management for policies that are deleted in Intune, including soft delete, UI indicators, and orphaned policy handling.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
Currently, when a policy is deleted in Intune:
|
||||||
|
- ❌ Policy remains in TenantAtlas database indefinitely
|
||||||
|
- ❌ No indication that policy no longer exists in Intune
|
||||||
|
- ❌ Backup Items reference "ghost" policies
|
||||||
|
- ❌ Users cannot distinguish between active and deleted policies
|
||||||
|
|
||||||
|
**Discovered during**: Feature 004 manual testing (user deleted policy in Intune)
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
- **Primary**: Implement soft delete for policies removed from Intune
|
||||||
|
- **Secondary**: Show clear UI indicators for deleted policies
|
||||||
|
- **Tertiary**: Maintain referential integrity for Backup Items and Policy Versions
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- **Policy Sync**: Detect missing policies during `SyncPoliciesJob`
|
||||||
|
- **Data Model**: Add `deleted_at`, `deleted_by` columns (Laravel Soft Delete pattern)
|
||||||
|
- **UI**: Badge indicators, filters, restore capability
|
||||||
|
- **Audit**: Log when policies are soft-deleted and restored
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Stories
|
||||||
|
|
||||||
|
### User Story 1 - Automatic Soft Delete on Sync
|
||||||
|
|
||||||
|
**As a system administrator**, I want policies deleted in Intune to be automatically marked as deleted in TenantAtlas, so that the inventory reflects the current Intune state.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
1. **Given** a policy exists in TenantAtlas with `external_id` "abc-123",
|
||||||
|
**When** the next policy sync runs and "abc-123" is NOT returned by Graph API,
|
||||||
|
**Then** the policy is soft-deleted (sets `deleted_at = now()`)
|
||||||
|
|
||||||
|
2. **Given** a soft-deleted policy,
|
||||||
|
**When** it re-appears in Intune (same `external_id`),
|
||||||
|
**Then** the policy is automatically restored (`deleted_at = null`)
|
||||||
|
|
||||||
|
3. **Given** multiple policies are deleted in Intune,
|
||||||
|
**When** sync runs,
|
||||||
|
**Then** all missing policies are soft-deleted in a single transaction
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - UI Indicators for Deleted Policies
|
||||||
|
|
||||||
|
**As an admin**, I want to see clear indicators when viewing deleted policies, so I understand their status.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
1. **Given** I view a Backup Item referencing a deleted policy,
|
||||||
|
**When** I see the policy name,
|
||||||
|
**Then** it shows a red "Deleted" badge next to the name
|
||||||
|
|
||||||
|
2. **Given** I view the Policies list,
|
||||||
|
**When** I enable the "Show Deleted" filter,
|
||||||
|
**Then** deleted policies appear with:
|
||||||
|
- Red "Deleted" badge
|
||||||
|
- Deleted date in "Last Synced" column
|
||||||
|
- Grayed-out appearance
|
||||||
|
|
||||||
|
3. **Given** a policy was deleted,
|
||||||
|
**When** I view the Policy detail page,
|
||||||
|
**Then** I see:
|
||||||
|
- Warning banner: "This policy was deleted from Intune on {date}"
|
||||||
|
- All data remains readable (versions, snapshots, metadata)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Restore Workflow
|
||||||
|
|
||||||
|
**As an admin**, I want to restore a deleted policy from backup, so I can recover accidentally deleted configurations.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
1. **Given** I view a deleted policy's detail page,
|
||||||
|
**When** I click the "Restore to Intune" action,
|
||||||
|
**Then** the restore wizard opens pre-filled with the latest policy snapshot
|
||||||
|
|
||||||
|
2. **Given** a policy is successfully restored to Intune,
|
||||||
|
**When** the next sync runs,
|
||||||
|
**Then** the policy is automatically undeleted in TenantAtlas (`deleted_at = null`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Functional Requirements
|
||||||
|
|
||||||
|
### Data Model
|
||||||
|
|
||||||
|
**FR-005.1**: Policies table MUST use Laravel Soft Delete pattern:
|
||||||
|
```php
|
||||||
|
Schema::table('policies', function (Blueprint $table) {
|
||||||
|
$table->softDeletes(); // deleted_at
|
||||||
|
$table->string('deleted_by')->nullable(); // admin email who triggered deletion
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**FR-005.2**: Policy model MUST use `SoftDeletes` trait:
|
||||||
|
```php
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class Policy extends Model {
|
||||||
|
use SoftDeletes;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Policy Sync Behavior
|
||||||
|
|
||||||
|
**FR-005.3**: `PolicySyncService::syncPolicies()` MUST detect missing policies:
|
||||||
|
- Collect all `external_id` values returned by Graph API
|
||||||
|
- Query existing policies for this tenant: `whereNotIn('external_id', $currentExternalIds)`
|
||||||
|
- Soft delete missing policies: `each(fn($p) => $p->delete())`
|
||||||
|
|
||||||
|
**FR-005.4**: System MUST restore policies that re-appear:
|
||||||
|
- Check if policy exists with `Policy::withTrashed()->where('external_id', $id)->first()`
|
||||||
|
- If soft-deleted: call `$policy->restore()`
|
||||||
|
- Update `last_synced_at` timestamp
|
||||||
|
|
||||||
|
**FR-005.5**: System MUST log audit entries:
|
||||||
|
- `policy.deleted` (when soft-deleted during sync)
|
||||||
|
- `policy.restored` (when re-appears in Intune)
|
||||||
|
|
||||||
|
### UI Display
|
||||||
|
|
||||||
|
**FR-005.6**: PolicyResource table MUST:
|
||||||
|
- Default query: exclude soft-deleted policies
|
||||||
|
- Add filter "Show Deleted" (includes `withTrashed()` in query)
|
||||||
|
- Show "Deleted" badge for soft-deleted policies
|
||||||
|
|
||||||
|
**FR-005.7**: BackupItemsRelationManager MUST:
|
||||||
|
- Show "Deleted" badge when `policy->trashed()` returns true
|
||||||
|
- Allow viewing deleted policy details (read-only)
|
||||||
|
|
||||||
|
**FR-005.8**: Policy detail view MUST:
|
||||||
|
- Show warning banner when policy is soft-deleted
|
||||||
|
- Display deletion date and reason (if available)
|
||||||
|
- Disable edit actions (policy no longer exists in Intune)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Non-Functional Requirements
|
||||||
|
|
||||||
|
**NFR-005.1**: Soft delete MUST NOT break existing features:
|
||||||
|
- Backup Items keep valid foreign keys
|
||||||
|
- Policy Versions remain accessible
|
||||||
|
- Restore functionality works for deleted policies
|
||||||
|
|
||||||
|
**NFR-005.2**: Performance: Sync detection MUST NOT cause N+1 queries:
|
||||||
|
- Use single `whereNotIn()` query to find missing policies
|
||||||
|
- Batch soft-delete operation
|
||||||
|
|
||||||
|
**NFR-005.3**: Data retention: Soft-deleted policies MUST be retained for audit purposes (no automatic purging)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Phase 1: Data Model (30 min)
|
||||||
|
1. Create migration for `policies` soft delete columns
|
||||||
|
2. Add `SoftDeletes` trait to Policy model
|
||||||
|
3. Run migration on dev environment
|
||||||
|
|
||||||
|
### Phase 2: Sync Logic (1 hour)
|
||||||
|
1. Update `PolicySyncService::syncPolicies()`
|
||||||
|
- Track current external IDs from Graph
|
||||||
|
- Soft delete missing policies
|
||||||
|
- Restore re-appeared policies
|
||||||
|
2. Add audit logging
|
||||||
|
3. Test with manual deletion in Intune
|
||||||
|
|
||||||
|
### Phase 3: UI Indicators (1.5 hours)
|
||||||
|
1. Update `PolicyResource`:
|
||||||
|
- Add "Show Deleted" filter
|
||||||
|
- Add "Deleted" badge column
|
||||||
|
- Modify query to exclude deleted by default
|
||||||
|
2. Update `BackupItemsRelationManager`:
|
||||||
|
- Show "Deleted" badge for `policy->trashed()`
|
||||||
|
3. Update Policy detail view:
|
||||||
|
- Warning banner for deleted policies
|
||||||
|
- Disable edit actions
|
||||||
|
|
||||||
|
### Phase 4: Testing (1 hour)
|
||||||
|
1. Unit tests:
|
||||||
|
- Test soft delete on sync
|
||||||
|
- Test restore on re-appearance
|
||||||
|
2. Feature tests:
|
||||||
|
- E2E sync with deleted policies
|
||||||
|
- UI filter behavior
|
||||||
|
3. Manual QA:
|
||||||
|
- Delete policy in Intune → sync → verify soft delete
|
||||||
|
- Re-create policy → sync → verify restore
|
||||||
|
|
||||||
|
**Total Estimated Duration**: ~4-5 hours
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risks & Mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| Foreign key constraints block soft delete | Laravel soft delete only sets timestamp, constraints remain valid |
|
||||||
|
| Bulk delete impacts performance | Use chunked queries if tenant has 1000+ policies |
|
||||||
|
| Deleted policies clutter UI | Default filter hides them, "Show Deleted" is opt-in |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
1. ✅ Policies deleted in Intune are soft-deleted in TenantAtlas within 1 sync cycle
|
||||||
|
2. ✅ Re-appearing policies are automatically restored
|
||||||
|
3. ✅ UI clearly indicates deleted status
|
||||||
|
4. ✅ Backup Items and Versions remain accessible for deleted policies
|
||||||
|
5. ✅ No breaking changes to existing features
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Features
|
||||||
|
- Feature 004: Assignments & Scope Tags (discovered this issue during testing)
|
||||||
|
- Feature 001: Backup/Restore (must work with deleted policies)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Status**: Planned (Post-Feature 004)
|
||||||
|
**Priority**: P2 (Quality of Life improvement)
|
||||||
|
**Created**: 2025-12-22
|
||||||
|
**Author**: AI + Ahmed
|
||||||
|
**Next Steps**: Implement after Feature 004 Phase 3 testing complete
|
||||||
Loading…
Reference in New Issue
Block a user