docs(004): Add Graph API permissions documentation

- 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
This commit is contained in:
Ahmed Darrazi 2025-12-22 16:03:21 +01:00
parent 9fbcb816d9
commit 0e42164937
7 changed files with 432 additions and 20 deletions

View File

@ -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.

View File

@ -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')

View File

@ -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) {

View File

@ -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 [];

View File

@ -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
View 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)

View 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