tenantpilot/docs/architecture/intune-migration-guide.md
Ahmed Darrazi 41e80b6c0c
All checks were successful
Trigger Cloudarix Deploy / call-webhook (push) Successful in 2s
feat(policy-explorer-v2): implement MVP Phase 1-3
 New Features
- Advanced data table with TanStack Table v8 + Server Actions
- Server-side pagination (10/25/50/100 rows per page)
- Multi-column sorting with visual indicators
- Column management (show/hide, resize) persisted to localStorage
- URL state synchronization for shareable filtered views
- Sticky header with compact/comfortable density modes

📦 Components Added
- PolicyTableV2.tsx - Main table with TanStack integration
- PolicyTableColumns.tsx - 7 column definitions with sorting
- PolicyTablePagination.tsx - Pagination controls
- PolicyTableToolbar.tsx - Density toggle + column visibility menu
- ColumnVisibilityMenu.tsx - Show/hide columns dropdown

🔧 Hooks Added
- usePolicyTable.ts - TanStack Table initialization
- useURLState.ts - URL query param sync with nuqs
- useTablePreferences.ts - localStorage persistence

🎨 Server Actions Updated
- getPolicySettingsV2 - Pagination + sorting + filtering + Zod validation
- exportPolicySettingsCSV - Server-side CSV generation (max 5000 rows)

📚 Documentation Added
- Intune Migration Guide (1400+ lines) - Reverse engineering strategy
- Intune Reference Version tracking
- Tasks completed: 22/62 (Phase 1-3)

 Zero TypeScript compilation errors
 All MVP success criteria met (pagination, sorting, column management)
 Ready for Phase 4-7 (filtering, export, detail view, polish)

Refs: specs/004-policy-explorer-v2/tasks.md
2025-12-10 00:18:05 +01:00

33 KiB

Intune Reverse Engineering Migration Guide

Version: 1.0.0
Last Updated: 2025-12-09
PowerShell Reference: Commit 2eaf3257 (2025-12-09)
Status: Active

Table of Contents

  1. Overview
  2. PowerShell Reference Location
  3. Step-by-Step Implementation Process
  4. Data Points to Extract
  5. PowerShell to TypeScript Pattern Mapping
  6. Concrete Examples
  7. Troubleshooting API Discrepancies
  8. Understanding Existing Implementation Patterns
  9. Common Questions (FAQ)
  10. Edge Cases & Advanced Topics
  11. Complete End-to-End Example
  12. Reference Implementations

Overview

This guide documents the reverse engineering strategy for implementing Intune sync functionality in TenantPilot. Instead of relying solely on Microsoft Graph API documentation (which is often incomplete or outdated), we analyze the proven PowerShell implementation from IntuneManagement by Mikael Karlsson to discover the actual API patterns, undocumented parameters, and data transformation logic.

Why This Approach?

Microsoft Graph API documentation frequently:

  • Omits required query parameters ($expand, $filter, $select)
  • Incorrectly documents API versions (beta vs v1.0)
  • Lacks examples of property cleanup/transformation
  • Doesn't specify pagination patterns
  • Misses edge cases in error handling

The IntuneManagement PowerShell tool has been battle-tested in production environments and contains the actual working implementation of Intune API interactions.

When to Use This Guide

  • Before implementing any new Intune sync job: Follow the step-by-step process
  • When troubleshooting API discrepancies: Compare TypeScript vs PowerShell
  • When onboarding new team members: Understand rationale for existing patterns
  • When PowerShell reference is updated: Review for breaking changes

Scope

This guide focuses on the process of reverse engineering PowerShell → TypeScript. It does NOT modify existing sync job implementations. Use this guide for new implementations and troubleshooting existing ones.


PowerShell Reference Location

Repository Path: reference/IntuneManagement-master/

Key Directories

  • Modules/: Core PowerShell modules organized by resource type

    • ConfigurationPolicies.psm1 - Device configuration policies
    • Applications.psm1 - App management
    • DeviceCompliance.psm1 - Compliance policies
    • WindowsUpdateRings.psm1 - Windows Update for Business
    • SettingsCatalog.psm1 - Settings Catalog policies
  • Extensions/: Utility modules for authentication, API calls, documentation

    • MSALAuthentication.psm1 - Authentication patterns
    • IntuneTools.psm1 - Common API utilities
    • EndpointManagerInfo.psm1 - Endpoint metadata
  • Core.psm1: Base functionality for Graph API calls

Version Tracking

Current version: Commit 2eaf3257 (2025-12-09)
See docs/architecture/intune-reference-version.md for version history and update process.

Finding the Right Module

  1. Identify the Intune resource type you need to implement (e.g., "Compliance Policies")
  2. Search Modules/ directory for related .psm1 file (e.g., DeviceCompliance.psm1)
  3. If not obvious, search by Graph API endpoint (e.g., grep -r "deviceManagement/deviceCompliancePolicies" reference/IntuneManagement-master/)

Step-by-Step Implementation Process

Follow this process before writing any TypeScript code for a new Intune sync job.

Phase 1: Locate PowerShell Reference

  1. Identify the resource type from the feature request (e.g., "App Protection Policies")
  2. Search for the PowerShell module:
    find reference/IntuneManagement-master/Modules -name "*Protection*.psm1"
    # or search by keyword
    grep -r "AppProtection" reference/IntuneManagement-master/Modules/
    
  3. Open the corresponding .psm1 file in your editor

Phase 2: Extract API Endpoint Pattern

  1. Find the Graph API call - Look for Invoke-MSGraphRequest or Invoke-GraphRequest:

    $policies = Invoke-MSGraphRequest -Url "/beta/deviceAppManagement/managedAppPolicies"
    
  2. Document the endpoint:

    • Full path: /beta/deviceAppManagement/managedAppPolicies
    • API version: beta (not v1.0)
    • Base segment: deviceAppManagement
  3. Check for query parameters:

    $policies = Invoke-MSGraphRequest -Url "/beta/deviceManagement/windowsUpdateForBusinessConfigurations?`$expand=assignments"
    
    • Note the $expand=assignments parameter

Phase 3: Identify Data Transformation Logic

  1. Look for property deletions:

    $policy.PSObject.Properties.Remove('createdDateTime')
    $policy.PSObject.Properties.Remove('lastModifiedDateTime')
    

    → These properties must be deleted before saving (read-only)

  2. Look for type conversions:

    $policy.scheduledInstallDay = [int]$policy.scheduledInstallDay
    

    → Type must be coerced

  3. Look for nested object flattening:

    if($policy.assignments) {
        $policy | Add-Member -NotePropertyName assignmentIds -NotePropertyValue ($policy.assignments.id)
    }
    

    → Extract IDs from nested structure

Phase 4: Document Undocumented Behaviors

  1. Check for beta API requirements:

    # Uses /beta because v1.0 doesn't support this resource yet
    
  2. Note pagination patterns:

    do {
        $response = Invoke-MSGraphRequest -Url $url
        $policies += $response.value
        $url = $response.'@odata.nextLink'
    } while($url)
    
  3. Document error handling quirks:

    # 404 is expected when no policies exist - don't treat as error
    if($response.StatusCode -eq 404) { return @() }
    

Phase 5: Map to TypeScript Implementation

  1. Create the sync job file: worker/jobs/sync<ResourceType>.ts
  2. Translate the endpoint: PowerShell URL → TypeScript Graph client call
  3. Implement data transformation: Match PowerShell cleanup logic
  4. Add pagination: Match PowerShell pagination pattern
  5. Handle errors: Replicate PowerShell error handling

Phase 6: Validate

  1. Compare API calls: Use Graph Explorer to verify endpoint and parameters match
  2. Compare data shape: Ensure TypeScript returns same structure as PowerShell
  3. Test edge cases: Empty result sets, large result sets, missing properties
  4. Document deviations: If TypeScript differs from PowerShell, document why

Data Points to Extract

When analyzing PowerShell reference code, extract these critical data points:

Required Extractions

  • Graph API Endpoint

    • Full URL path (e.g., /beta/deviceManagement/configurationPolicies)
    • API version (beta or v1.0)
  • Query Parameters

    • $filter - Filtering criteria (e.g., $filter=isof('microsoft.graph.windows10GeneralConfiguration'))
    • $expand - Related entities to include (e.g., $expand=assignments,settings)
    • $select - Specific properties to return (e.g., $select=id,displayName,createdDateTime)
    • $top - Page size limit (e.g., $top=999)
    • $orderby - Sorting (e.g., $orderby=displayName)
  • Property Cleanup Logic

    • Read-only properties to delete (e.g., createdDateTime, lastModifiedDateTime, @odata.type)
    • Computed properties to remove (e.g., version, supportsScopeTags)
  • Type Transformations

    • Type coercions (string → int, string → boolean)
    • Date formatting (ISO 8601 strings)
    • Enum value mappings
  • Pagination Pattern

    • How to handle @odata.nextLink
    • Maximum page size
    • Total count handling
  • Error Handling

    • Expected error codes (e.g., 404 for empty results)
    • Retry logic
    • Rate limiting handling

🔍 Optional Extractions (If Present)

  • Nested Object Handling

    • Assignment structures
    • Setting collections
    • Related entity expansions
  • Conditional Logic

    • Resource type-specific branching
    • Feature flag checks
    • Tenant capability checks
  • Batch Operations

    • Batch request patterns
    • Transaction boundaries

PowerShell to TypeScript Pattern Mapping

Common PowerShell patterns and their TypeScript equivalents:

Pattern 1: Basic Graph API Call

PowerShell:

$policies = Invoke-MSGraphRequest -Url "/beta/deviceManagement/configurationPolicies"

TypeScript:

const response = await graphClient
  .api('/deviceManagement/configurationPolicies')
  .version('beta')
  .get();
const policies = response.value;

Pattern 2: Query Parameters

PowerShell:

$url = "/beta/deviceManagement/configurationPolicies?`$expand=assignments&`$filter=isof('microsoft.graph.configurationPolicy')"
$policies = Invoke-MSGraphRequest -Url $url

TypeScript:

const response = await graphClient
  .api('/deviceManagement/configurationPolicies')
  .version('beta')
  .expand('assignments')
  .filter("isof('microsoft.graph.configurationPolicy')")
  .get();

Pattern 3: Pagination

PowerShell:

$allPolicies = @()
$url = "/beta/deviceManagement/configurationPolicies"
do {
    $response = Invoke-MSGraphRequest -Url $url
    $allPolicies += $response.value
    $url = $response.'@odata.nextLink'
} while($url)

TypeScript:

let allPolicies = [];
let url = '/deviceManagement/configurationPolicies';
while (url) {
  const response = await graphClient.api(url).version('beta').get();
  allPolicies.push(...response.value);
  url = response['@odata.nextLink'];
}

Pattern 4: Property Deletion

PowerShell:

$policy.PSObject.Properties.Remove('createdDateTime')
$policy.PSObject.Properties.Remove('lastModifiedDateTime')
$policy.PSObject.Properties.Remove('@odata.context')

TypeScript:

delete policy.createdDateTime;
delete policy.lastModifiedDateTime;
delete policy['@odata.context'];

Pattern 5: Type Conversion

PowerShell:

$setting.value = [int]$setting.value
$policy.isAssigned = [bool]$policy.isAssigned

TypeScript:

setting.value = parseInt(setting.value, 10);
policy.isAssigned = Boolean(policy.isAssigned);

Concrete Examples

Example 1: Windows Update Rings

Feature Request: Implement sync for Windows Update for Business configurations

Step 1: Locate PowerShell Module

$ find reference/IntuneManagement-master -name "*Update*.psm1"
reference/IntuneManagement-master/Modules/WindowsUpdateRings.psm1

Step 2: Extract API Pattern from PowerShell

# From WindowsUpdateRings.psm1
$rings = Invoke-MSGraphRequest -Url "/beta/deviceManagement/windowsUpdateForBusinessConfigurations"

Extracted Data Points:

  • Endpoint: /beta/deviceManagement/windowsUpdateForBusinessConfigurations
  • API Version: beta (not available in v1.0)
  • Query Params: None required for list operation
  • Property Cleanup: createdDateTime, lastModifiedDateTime must be removed

Step 3: TypeScript Implementation

// worker/jobs/syncWindowsUpdateRings.ts
import { graphClient } from '../utils/graphClient';
import { db } from '@/lib/db';
import { windowsUpdateRings } from '@/lib/db/schema';

export async function syncWindowsUpdateRings(tenantId: string) {
  const response = await graphClient
    .api('/deviceManagement/windowsUpdateForBusinessConfigurations')
    .version('beta') // PowerShell uses beta
    .get();
  
  const rings = response.value;
  
  // Cleanup properties (matches PowerShell)
  for (const ring of rings) {
    delete ring.createdDateTime;
    delete ring.lastModifiedDateTime;
    delete ring['@odata.context'];
  }
  
  // Save to database
  await db.insert(windowsUpdateRings).values(
    rings.map(ring => ({
      tenantId,
      intuneId: ring.id,
      displayName: ring.displayName,
      data: ring,
      lastSyncedAt: new Date()
    }))
  ).onConflictDoUpdate({
    target: [windowsUpdateRings.tenantId, windowsUpdateRings.intuneId],
    set: { data: ring, lastSyncedAt: new Date() }
  });
}

Example 2: Settings Catalog with $expand

Feature Request: Implement Settings Catalog policy sync

Step 1: PowerShell Analysis

# From SettingsCatalog.psm1
$url = "/beta/deviceManagement/configurationPolicies?`$expand=settings"
$policies = Invoke-MSGraphRequest -Url $url

Key Discovery: The $expand=settings parameter is not documented in Microsoft Graph API docs, but is required to get policy settings in a single call. Without it, you'd need a separate API call per policy to fetch settings.

Step 2: TypeScript Implementation

// worker/jobs/syncSettingsCatalog.ts
export async function syncSettingsCatalog(tenantId: string) {
  const response = await graphClient
    .api('/deviceManagement/configurationPolicies')
    .version('beta')
    .expand('settings') // CRITICAL: Discovered from PowerShell reference
    .get();
  
  const policies = response.value;
  
  // Now policies include nested settings array
  for (const policy of policies) {
    console.log(`Policy ${policy.displayName} has ${policy.settings?.length || 0} settings`);
  }
  
  // Save to database...
}

Without PowerShell Reference: You would make N+1 API calls (1 for policies list, N for each policy's settings), causing:

  • Slower sync performance
  • Higher API rate limit consumption
  • Unnecessary complexity

Example 3: Invoke-MSGraphRequest Translation

PowerShell Pattern:

$requestParams = @{
    Url = "/beta/deviceManagement/deviceCompliancePolicies"
    Method = "GET"
    Headers = @{
        "Accept" = "application/json"
    }
}
$policies = Invoke-MSGraphRequest @requestParams

TypeScript Equivalent:

const response = await graphClient
  .api('/deviceManagement/deviceCompliancePolicies')
  .version('beta')
  .header('Accept', 'application/json')
  .get();
const policies = response.value;

Key Mappings:

  • Url.api(path).version(version)
  • Method = "GET".get()
  • Method = "POST".post(body)
  • Method = "PATCH".patch(body)
  • Headers.header(name, value)

Example 4: Property Cleanup Patterns

PowerShell Pattern:

# From ConfigurationPolicies.psm1
function Remove-ReadOnlyProperties($policy) {
    $policy.PSObject.Properties.Remove('id')
    $policy.PSObject.Properties.Remove('createdDateTime')
    $policy.PSObject.Properties.Remove('lastModifiedDateTime')
    $policy.PSObject.Properties.Remove('settingCount')
    $policy.PSObject.Properties.Remove('creationSource')
    $policy.PSObject.Properties.Remove('roleScopeTagIds')
    
    # Remove OData metadata
    $policy.PSObject.Properties | Where-Object { $_.Name -like '@odata.*' } | ForEach-Object {
        $policy.PSObject.Properties.Remove($_.Name)
    }
}

Why This Matters: These properties are:

  • Read-only: Graph API will reject POST/PATCH requests containing them
  • Server-managed: Values are computed by Intune, not settable by clients
  • Metadata: OData annotations are for response enrichment only

TypeScript Implementation:

function removeReadOnlyProperties(policy: any): void {
  // Core read-only fields
  delete policy.id;
  delete policy.createdDateTime;
  delete policy.lastModifiedDateTime;
  delete policy.settingCount;
  delete policy.creationSource;
  delete policy.roleScopeTagIds;
  
  // Remove all OData metadata
  Object.keys(policy).forEach(key => {
    if (key.startsWith('@odata.')) {
      delete policy[key];
    }
  });
}

Usage:

const policy = await graphClient.api(`/deviceManagement/configurationPolicies/${id}`).get();
removeReadOnlyProperties(policy);
await graphClient.api('/deviceManagement/configurationPolicies').post(policy); // Now succeeds

Troubleshooting API Discrepancies

When TypeScript implementation returns different data than PowerShell reference, follow this systematic process:

Troubleshooting Checklist

  • 1. Compare Endpoints: Verify exact URL path matches PowerShell
  • 2. Check API Version: Confirm beta vs v1.0 matches
  • 3. Verify Query Parameters: Ensure $expand, $filter, $select match
  • 4. Inspect Response Data: Compare raw JSON from both implementations
  • 5. Check Property Cleanup: Verify same properties are deleted
  • 6. Review Type Conversions: Confirm same type coercions applied
  • 7. Test Pagination: Ensure all pages are fetched correctly
  • 8. Validate Error Handling: Check for missing error case handling

Example Troubleshooting Session

Problem: TypeScript sync returns incomplete assignment data for Configuration Policies

Step 1: Compare API Calls

// Current TypeScript (WRONG)
const response = await graphClient
  .api('/deviceManagement/configurationPolicies')
  .version('beta')
  .get();
// Result: Policies have no assignments property

Step 2: Check PowerShell Reference

# From PowerShell (CORRECT)
$policies = Invoke-MSGraphRequest -Url "/beta/deviceManagement/configurationPolicies?`$expand=assignments"
# Result: Policies include assignments array

Root Cause: Missing $expand=assignments parameter

Step 3: Fix TypeScript

// Fixed TypeScript
const response = await graphClient
  .api('/deviceManagement/configurationPolicies')
  .version('beta')
  .expand('assignments') // Added this
  .get();
// Result: Policies now include assignments

Example: 400 Bad Request Debugging

Problem: Sync job fails with "400 Bad Request" error

Step 1: Capture Request Details

try {
  await graphClient.api('/deviceManagement/someEndpoint').version('v1.0').get();
} catch (error) {
  console.error('Request URL:', error.requestUrl);
  console.error('Response:', error.body);
}

Step 2: Check PowerShell for API Version

# PowerShell uses /beta, not /v1.0
$data = Invoke-MSGraphRequest -Url "/beta/deviceManagement/someEndpoint"

Root Cause: Endpoint only exists in beta API

Step 3: Fix

await graphClient.api('/deviceManagement/someEndpoint').version('beta').get(); // Changed to beta

Understanding Existing Implementation Patterns

Why We Delete Properties

Pattern You See:

delete policy.createdDateTime;
delete policy.lastModifiedDateTime;

Rationale: This matches PowerShell cleanup logic. These properties are:

  • Read-only: Set by Graph API, not modifiable by clients
  • Validation failures: Including them in POST/PATCH causes 400 errors
  • Intentional: Not a refactoring opportunity - DO NOT "clean up" by keeping them

PowerShell Reference:

$policy.PSObject.Properties.Remove('createdDateTime')
$policy.PSObject.Properties.Remove('lastModifiedDateTime')

To New Developers: If you see property deletions in sync jobs, check the PowerShell reference before "fixing" them. They're intentional.


When to Use Beta vs V1.0 API

Pattern You See:

.version('beta')

Decision Process:

  1. Check PowerShell reference first: If PowerShell uses /beta, you must use beta
  2. Don't "upgrade" to v1.0 without verifying in PowerShell reference
  3. Beta is not always unstable: Many Intune features are beta-only for years

Why Beta:

  • Resource type doesn't exist in v1.0 yet (e.g., Settings Catalog)
  • Required properties only available in beta
  • Microsoft hasn't stabilized the API schema

PowerShell Example:

# Uses /beta because v1.0 doesn't support this yet
$policies = Invoke-MSGraphRequest -Url "/beta/deviceManagement/configurationPolicies"

TypeScript Equivalent:

// Must use beta to match PowerShell
const response = await graphClient
  .api('/deviceManagement/configurationPolicies')
  .version('beta') // DO NOT change to 'v1.0'
  .get();

Common Questions (FAQ)

Q: Should I always use the PowerShell reference?

A: Yes, for Intune-related features. The PowerShell tool has been battle-tested and contains the actual working patterns. Microsoft Graph docs are often incomplete.

Q: What if the PowerShell reference is outdated?

A: Check the version tracking in docs/architecture/intune-reference-version.md. If outdated, update the reference and review for breaking changes before implementing new features.

Q: What if there's no PowerShell equivalent for my feature?

A: Use the Fallback Process:

  1. Use official Graph API docs
  2. Test against at least 2 different tenants
  3. Validate with 5+ resource instances
  4. Document any undocumented behaviors discovered
  5. Consider contributing findings back to IntuneManagement project

Q: Can I skip property cleanup if Graph API doesn't complain?

A: No. Match the PowerShell cleanup logic exactly. Graph API behavior can change, and undocumented acceptance of read-only properties shouldn't be relied upon.

Q: Why not just use the PowerShell tool directly instead of TypeScript?

A: Our architecture requires TypeScript for type safety, integration with Next.js, and consistency with the rest of the codebase (see Constitution in .specify/memory/constitution.md). We use PowerShell as a reference, not as the implementation.


Edge Cases & Advanced Topics

Versioning Strategy

Principle: Document which PowerShell commit was used as reference for each feature implementation.

Process:

  1. When implementing a new sync job, note the current PowerShell reference commit

  2. Add a comment in the TypeScript file:

    /**
     * Windows Update Rings Sync
     * 
     * PowerShell Reference: commit 2eaf3257 (2025-12-09)
     * Module: WindowsUpdateRings.psm1
     * Last Verified: 2025-12-09
     */
    
  3. When PowerShell reference is updated, review all sync jobs and update version references

Why This Matters: If a sync job breaks after PowerShell update, you can:

  • Diff between old and new PowerShell commit
  • Identify breaking changes
  • Update TypeScript to match new patterns

Fallback Process for Missing PowerShell Reference

When to Use: New Intune feature has no corresponding PowerShell module yet.

Process:

  1. Check Official Docs: Start with Microsoft Graph API documentation
  2. Use Graph Explorer: Test endpoints interactively at https://developer.microsoft.com/en-us/graph/graph-explorer
  3. Extensive Testing:
    • Test against at least 2 different test tenants
    • Create/read/update/delete at least 5 resource instances
    • Test with different property combinations
    • Test pagination with large result sets
  4. Document Assumptions:
    /**
     * [NO POWERSHELL REFERENCE]
     * 
     * Implementation based on Graph API docs (2025-12-09)
     * Assumptions:
     * - Uses beta API (not documented if v1.0 exists)
     * - No $expand parameters documented
     * - Property cleanup based on trial-and-error
     * 
     * TODO: Check IntuneManagement project for updates
     */
    
  5. Monitor for PowerShell Update: Check IntuneManagement repo periodically for new modules

Handling PowerShell Updates

Scenario: PowerShell reference is updated with breaking changes.

Detection:

  1. Monitor IntuneManagement GitHub repository for commits
  2. Review release notes in reference/IntuneManagement-master/ReleaseNotes.md
  3. Check commit messages for "breaking change" or "BREAKING"

Update Process:

  1. Pull Latest Changes:

    cd reference/IntuneManagement-master
    git fetch origin
    git log HEAD..origin/master --oneline # Review changes
    git merge origin/master
    
  2. Document New Version: Update docs/architecture/intune-reference-version.md with new commit hash

  3. Test Critical Sync Jobs:

    • Run sync jobs against test tenant
    • Compare output before/after update
    • Verify no data loss or corruption
  4. Update TypeScript Implementations:

    • Review changed PowerShell modules
    • Update corresponding TypeScript sync jobs
    • Update version comments in code
  5. Document Breaking Changes: Add to this guide's version history


Deprecated Features

Scenario: PowerShell module exists but Microsoft deprecated the Intune feature.

Identification:

  • PowerShell comments mention "deprecated"
  • Graph API returns 404 or "feature not supported"
  • Microsoft docs show deprecation notice

Handling:

  1. Mark as Reference Only: Add comment to TypeScript:

    /**
     * [DEPRECATED FEATURE]
     * 
     * This sync job is based on deprecated PowerShell module.
     * DO NOT use for new implementations.
     * 
     * PowerShell Reference: DeviceCompliance.psm1 (deprecated 2024-06-01)
     * Replacement: Use ComplianceSettings.psm1 instead
     */
    
  2. Don't Remove Code: Keep for reference, but disable in job queue

  3. Document Migration Path: If replacement exists, document how to migrate


PowerShell Quirks vs Intentional Patterns

Problem: How to distinguish between PowerShell bugs and intentional API patterns?

Decision Framework:

Replicate These (Intentional Patterns)

  • Property cleanup logic (read-only field removal)
  • Specific $expand parameters
  • Beta vs v1.0 API version choices
  • Pagination patterns
  • Error handling for expected errors (404 for empty results)

Document But Don't Replicate (PowerShell Quirks)

  • PowerShell-specific syntax workarounds
  • Windows-specific file path handling
  • PowerShell module dependency quirks

Example of PowerShell Quirk:

# PowerShell-specific: Escape backtick for line continuation
$url = "/beta/deviceManagement/configurationPolicies?``$expand=assignments"

TypeScript Should NOT Replicate:

// DO NOT include backtick - that's PowerShell syntax
const url = '/deviceManagement/configurationPolicies?$expand=assignments';

Marking Quirks:

// [POWERSHELL QUIRK]: PowerShell uses [int] type cast here due to PS type system
// In TypeScript, parseInt() achieves the same result but is more idiomatic
const value = parseInt(setting.value, 10);

Complete End-to-End Example

Feature Request: Implement sync for Compliance Policies with assignments

Step 1: Locate PowerShell Module

$ find reference/IntuneManagement-master -name "*Compliance*.psm1"
reference/IntuneManagement-master/Modules/DeviceCompliance.psm1

Step 2: Analyze PowerShell Implementation

# From DeviceCompliance.psm1
function Get-CompliancePolicies {
    $url = "/beta/deviceManagement/deviceCompliancePolicies?`$expand=assignments"
    $policies = Invoke-MSGraphRequest -Url $url
    
    foreach($policy in $policies.value) {
        # Remove read-only properties
        $policy.PSObject.Properties.Remove('createdDateTime')
        $policy.PSObject.Properties.Remove('lastModifiedDateTime')
        $policy.PSObject.Properties.Remove('version')
        
        # Remove OData metadata
        $policy.PSObject.Properties.Remove('@odata.type')
    }
    
    return $policies.value
}

Extracted Data Points:

  • Endpoint: /beta/deviceManagement/deviceCompliancePolicies
  • API Version: beta
  • Query Params: $expand=assignments
  • Property Cleanup: createdDateTime, lastModifiedDateTime, version, @odata.type

Step 3: Create TypeScript Implementation

// worker/jobs/syncCompliancePolicies.ts

/**
 * Compliance Policies Sync
 * 
 * PowerShell Reference: commit 2eaf3257 (2025-12-09)
 * Module: DeviceCompliance.psm1
 * Last Verified: 2025-12-09
 */

import { graphClient } from '../utils/graphClient';
import { db } from '@/lib/db';
import { compliancePolicies } from '@/lib/db/schema';
import { eq, and } from 'drizzle-orm';

interface CompliancePolicy {
  id: string;
  displayName: string;
  description?: string;
  createdDateTime?: string; // Will be deleted
  lastModifiedDateTime?: string; // Will be deleted
  version?: number; // Will be deleted
  assignments?: Array<{
    id: string;
    target: any;
  }>;
  '@odata.type'?: string; // Will be deleted
  [key: string]: any;
}

export async function syncCompliancePolicies(tenantId: string): Promise<void> {
  console.log(`[${tenantId}] Starting compliance policies sync`);
  
  try {
    // Step 1: Fetch policies from Graph API (matches PowerShell)
    const response = await graphClient
      .api('/deviceManagement/deviceCompliancePolicies')
      .version('beta') // PowerShell uses beta
      .expand('assignments') // PowerShell uses $expand=assignments
      .get();
    
    const policies: CompliancePolicy[] = response.value;
    console.log(`[${tenantId}] Fetched ${policies.length} policies`);
    
    // Step 2: Clean up properties (matches PowerShell)
    for (const policy of policies) {
      delete policy.createdDateTime;
      delete policy.lastModifiedDateTime;
      delete policy.version;
      delete policy['@odata.type'];
    }
    
    // Step 3: Save to database
    for (const policy of policies) {
      await db
        .insert(compliancePolicies)
        .values({
          tenantId,
          intuneId: policy.id,
          displayName: policy.displayName,
          description: policy.description,
          data: policy, // Full policy JSON
          lastSyncedAt: new Date(),
        })
        .onConflictDoUpdate({
          target: [compliancePolicies.tenantId, compliancePolicies.intuneId],
          set: {
            displayName: policy.displayName,
            description: policy.description,
            data: policy,
            lastSyncedAt: new Date(),
          },
        });
    }
    
    console.log(`[${tenantId}] Compliance policies sync completed`);
  } catch (error) {
    console.error(`[${tenantId}] Compliance policies sync failed:`, error);
    throw error;
  }
}

Step 4: Register Job in Queue

// worker/index.ts

import { syncCompliancePolicies } from './jobs/syncCompliancePolicies';

// Register job processor
syncQueue.process('sync-compliance-policies', async (job) => {
  const { tenantId } = job.data;
  await syncCompliancePolicies(tenantId);
});

Step 5: Test Implementation

// scripts/test-compliance-sync.ts

import { syncCompliancePolicies } from '../worker/jobs/syncCompliancePolicies';

async function test() {
  const testTenantId = 'test-tenant-123';
  await syncCompliancePolicies(testTenantId);
  console.log('✓ Sync completed successfully');
}

test().catch(console.error);

Step 6: Validate Against PowerShell

  1. Run PowerShell sync:

    Import-Module ./reference/IntuneManagement-master/DeviceCompliance.psm1
    $policies = Get-CompliancePolicies
    $policies | ConvertTo-Json -Depth 10 > powershell-output.json
    
  2. Run TypeScript sync and export results:

    const policies = await syncCompliancePolicies('test-tenant');
    fs.writeFileSync('typescript-output.json', JSON.stringify(policies, null, 2));
    
  3. Compare outputs:

    diff powershell-output.json typescript-output.json
    # Should show minimal differences (only timestamps, etc.)
    

Reference Implementations

See these existing sync jobs for real-world examples:

  • Configuration Policies: worker/jobs/syncConfigurationPolicies.ts

    • Demonstrates $expand=assignments,settings
    • Complex property cleanup
    • Nested object handling
  • Applications: worker/jobs/syncApplications.ts

    • Demonstrates type-specific filtering
    • Binary data handling (app icons)
    • Conditional property cleanup
  • Device Enrollment: worker/jobs/syncEnrollmentProfiles.ts

    • Demonstrates beta-only endpoint
    • Multiple $expand parameters
    • Assignment group resolution

Each implementation includes:

  • PowerShell reference version comment
  • Module source reference
  • Last verified date
  • Inline explanations of undocumented behaviors

Updates & Maintenance

This Guide Is a Living Document

  • Add new examples when implementing new sync jobs
  • Update patterns when PowerShell reference changes
  • Document discoveries of undocumented API behaviors
  • Track PowerShell version in header when examples are updated

To Propose Changes:

  1. Create a feature branch
  2. Update this guide with new findings
  3. Submit PR with rationale for changes
  4. Link to PowerShell reference commit showing the pattern

Guide Version: 1.0.0
Last Reviewed: 2025-12-09
PowerShell Reference: 2eaf3257
Maintainers: See docs/architecture/intune-reference-version.md for update process