From 0e42164937c1c0b984e466121a5c676a9688c65c Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 22 Dec 2025 16:03:21 +0100 Subject: [PATCH] docs(004): Add Graph API permissions documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Created docs/PERMISSIONS.md with complete permission requirements - Added logging for 403 errors in ScopeTagResolver - Updated README with link to permissions documentation Issue: Scope tags show 'Unknown (ID: 0)' due to missing permission Required: DeviceManagementRBAC.Read.All with admin consent User must: 1. Go to Azure Portal → App Registration 2. Add DeviceManagementRBAC.Read.All permission 3. Grant admin consent 4. Wait 5-10 min for propagation 5. Clear cache: php artisan cache:clear --- README.md | 2 + app/Filament/Resources/PolicyResource.php | 5 + app/Services/AssignmentBackupService.php | 12 +- app/Services/Graph/ScopeTagResolver.php | 49 +++-- app/Services/Intune/BackupService.php | 2 +- docs/PERMISSIONS.md | 154 +++++++++++++++ specs/005-policy-lifecycle/spec.md | 228 ++++++++++++++++++++++ 7 files changed, 432 insertions(+), 20 deletions(-) create mode 100644 docs/PERMISSIONS.md create mode 100644 specs/005-policy-lifecycle/spec.md diff --git a/README.md b/README.md index e9213f9..85438b8 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ ## TenantPilot setup - `GRAPH_CLIENT_SECRET` - `GRAPH_SCOPE` (default `https://graph.microsoft.com/.default`) - 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): - 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. diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index c132df3..4ea521f 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -203,6 +203,11 @@ public static function infolist(Schema $schema): Schema public static function table(Table $table): 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([ Tables\Columns\TextColumn::make('display_name') ->label('Policy') diff --git a/app/Services/AssignmentBackupService.php b/app/Services/AssignmentBackupService.php index 84f99f6..300f45f 100644 --- a/app/Services/AssignmentBackupService.php +++ b/app/Services/AssignmentBackupService.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Models\BackupItem; +use App\Models\Tenant; use App\Services\Graph\AssignmentFetcher; use App\Services\Graph\GroupResolver; use App\Services\Graph\ScopeTagResolver; @@ -20,7 +21,7 @@ public function __construct( * Enrich a backup item with assignments and scope tag metadata. * * @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 array $policyPayload Full policy payload from Graph * @param bool $includeAssignments Whether to fetch and include assignments @@ -28,14 +29,14 @@ public function __construct( */ public function enrichWithAssignments( BackupItem $backupItem, - string $tenantId, + Tenant $tenant, string $policyId, array $policyPayload, bool $includeAssignments = false ): BackupItem { // Extract scope tags from payload (always available in policy) $scopeTagIds = $policyPayload['roleScopeTagIds'] ?? ['0']; - $scopeTagNames = $this->resolveScopeTagNames($scopeTagIds); + $scopeTagNames = $this->resolveScopeTagNames($scopeTagIds, $tenant); $metadata = $backupItem->metadata ?? []; $metadata['scope_tag_ids'] = $scopeTagIds; @@ -53,6 +54,7 @@ public function enrichWithAssignments( } // Fetch assignments from Graph API + $tenantId = $tenant->external_id ?? $tenant->tenant_id; $assignments = $this->assignmentFetcher->fetch($tenantId, $policyId); if (empty($assignments)) { @@ -108,9 +110,9 @@ public function enrichWithAssignments( /** * 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 = []; foreach ($scopeTagIds as $id) { diff --git a/app/Services/Graph/ScopeTagResolver.php b/app/Services/Graph/ScopeTagResolver.php index dfdb2ef..0460eeb 100644 --- a/app/Services/Graph/ScopeTagResolver.php +++ b/app/Services/Graph/ScopeTagResolver.php @@ -2,6 +2,7 @@ namespace App\Services\Graph; +use App\Models\Tenant; use Illuminate\Support\Facades\Cache; class ScopeTagResolver @@ -18,16 +19,17 @@ public function __construct( * Filters to requested IDs in memory. * * @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 */ - public function resolve(array $scopeTagIds): array + public function resolve(array $scopeTagIds, ?Tenant $tenant = null): array { if (empty($scopeTagIds)) { return []; } // Fetch all scope tags (cached) - $allScopeTags = $this->fetchAllScopeTags(); + $allScopeTags = $this->fetchAllScopeTags($tenant); // Filter to requested IDs 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). */ - 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 { - $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', - null, - ['$select' => 'id,displayName'] + $options ); - $scopeTags = $response['value'] ?? []; + $scopeTags = $graphResponse->data['value'] ?? []; - $this->logger->logDebug('Fetched scope tags', [ - 'count' => count($scopeTags), - ]); + // Check for 403 Forbidden (missing permissions) + 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; - } catch (GraphException $e) { - $this->logger->logWarning('Failed to fetch scope tags', [ + } catch (\Exception $e) { + // Fail soft - return empty array on any error + \Log::warning('Scope tag fetch exception', [ + 'tenant_id' => $tenant?->id, 'error' => $e->getMessage(), - 'context' => $e->context, ]); return []; diff --git a/app/Services/Intune/BackupService.php b/app/Services/Intune/BackupService.php index 97d47d8..a4f98a7 100644 --- a/app/Services/Intune/BackupService.php +++ b/app/Services/Intune/BackupService.php @@ -280,7 +280,7 @@ private function snapshotPolicy( if ($policy->policy_type === 'settingsCatalogPolicy') { $backupItem = $this->assignmentBackupService->enrichWithAssignments( backupItem: $backupItem, - tenantId: $tenant->external_id, + tenant: $tenant, policyId: $policy->external_id, policyPayload: $payload, includeAssignments: $includeAssignments diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md new file mode 100644 index 0000000..6eabcb8 --- /dev/null +++ b/docs/PERMISSIONS.md @@ -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) diff --git a/specs/005-policy-lifecycle/spec.md b/specs/005-policy-lifecycle/spec.md new file mode 100644 index 0000000..873c1dc --- /dev/null +++ b/specs/005-policy-lifecycle/spec.md @@ -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