Unlock the insights hidden in your Entra ID Governance that the Azure Portal doesn't show you.
FortigiGraph syncs Microsoft Graph data to Azure SQL with temporal versioning, enabling powerful governance insights, access analysis, and identity auditing that simply aren't possible through the Entra ID portal alone.
FortigiGraph provides a guided setup wizard that creates everything you need: Azure resources, App Registration with the right permissions, SQL Server, and a config file that drives all operations.
- PowerShell 7+ (recommended) or PowerShell 5.1
- Azure subscription with Contributor access
- Az PowerShell module:
Install-Module Az -Scope CurrentUser
# From PowerShell Gallery
Install-Module -Name FortigiGraph -Scope CurrentUser
# Or clone and import locally
git clone https://github.com/Fortigi/FortigiGraph.git
cd FortigiGraph
Import-Module .\FortigiGraph.psd1New-FGConfig -Path .\Config\mycompany.jsonThe wizard walks you through:
- Azure Login - Logs you in and selects your subscription
- Resource Group - Select an existing one or create a new one
- SQL Server - Creates an Azure SQL Server and database (or selects existing)
- Automation Account - Creates an Azure Automation Account for scheduled syncs
- App Registration - Creates the app with the right Graph API permissions:
User.Read.All,Group.Read.All,GroupMember.Read.AllDirectory.Read.All,EntitlementManagement.Read.AllAccessReview.Read.All,AuditLog.Read.All
- Sync Settings - Choose which data to sync (users, groups, memberships, access packages, etc.)
At the end, it saves everything to a config file and shows you the next steps.
# Authenticate to Microsoft Graph
Get-FGAccessToken -ConfigFile '.\Config\mycompany.json'
# Connect to Azure SQL Server (updates firewall automatically)
Connect-FGSQLServer -ConfigFile '.\Config\mycompany.json'Start-FGSync -ConfigFile '.\Config\mycompany.json'This syncs all enabled data types in parallel:
- Users, Groups, Group Memberships (direct, eligible, owners)
- Access Package Catalogs, Packages, Assignments, Policies, Requests, Reviews
- Creates performance indexes and analytical SQL views automatically
New-FGAzureAutomationAccount -ConfigFile '.\Config\mycompany.json'This creates an Azure Automation Account with:
- Encrypted variables for all credentials
- Runbooks for each sync type
- Daily schedules (optional)
- SQL firewall rule for Azure services
New-FGUI -ConfigFile '.\Config\mycompany.json'This deploys a web application to Azure App Service that visualizes your synced data as an interactive permission matrix. It is the recommended way to explore and analyze your Entra ID governance data.
See the Role Mining UI section below for full details on the UI features.
# Check what tables were created
Get-FGSQLTable
# Query some data
Invoke-FGSQLQuery -Query "SELECT COUNT(*) AS UserCount FROM GraphUsers"
Invoke-FGSQLQuery -Query "SELECT TOP 5 displayName, userPrincipalName FROM GraphUsers"
# Check sync log
Invoke-FGSQLQuery -Query "SELECT * FROM GraphSyncLog ORDER BY StartTime DESC"New-FGSQLReadOnlyUser -ConfigFile '.\Config\mycompany.json'FortigiGraph includes a web-based Role Mining UI that visualizes your permission data as an interactive matrix, making it easy to discover role patterns and governance gaps. This is the primary way most users will interact with their synced data.
# Deploy the UI (creates Azure App Service + App Registration + deploys code)
New-FGUI -ConfigFile '.\Config\mycompany.json'
# Deploy without authentication (for demos/development)
New-FGUI -ConfigFile '.\Config\mycompany.json' -NoAuth
# Redeploy after code changes (code-only, no resource creation)
Update-FGUI -ConfigFile '.\Config\mycompany.json'
# Remove the UI (stops billing)
Remove-FGUI -ConfigFile '.\Config\mycompany.json'| Layer | Technology | Purpose |
|---|---|---|
| Backend | Node.js + Express | REST API querying FortigiGraph SQL views |
| Frontend | React + Vite + Tailwind CSS + TanStack Table v8 | Interactive SPA |
| Authentication | Entra ID (MSAL) | Supports v1 + v2 JWT token formats; -NoAuth for demos |
| Deployment | Azure App Service (Linux, Node 20, P0v3) | Oryx build-on-deploy |
| Data Sources | vw_UserPermissionAssignments, vw_UserPermissionAssignmentViaAccessPackage, GraphUsers, GraphGroups |
SQL views + tables created by Start-FGSync |
The UI has five pages accessible via tab navigation:
The core visualization — an interactive user-group permission matrix.
- Rows = groups, Columns = users. Each cell shows the membership types (Direct, Indirect, Eligible, Owner) as colored badges
- Staircase Sort: Default row order groups rows by their leftmost access package, creating a visual staircase pattern. Unmanaged groups appear at the bottom
- Access Package Coloring: Managed cells are colored by their governing access package (15-color palette). Multi-AP cells show a count badge
- Access Package Columns: SOLL columns sorted first by category name, then by assignment count within each category; uncategorized access packages appear at the end. Category boundaries are marked with thicker borders and a colored indicator stripe.
- IST/SOLL Toggle: Filter to show all assignments, only unmanaged (IST), or only managed (SOLL)
- Server-Side User Limit: Slider (default 25) limits data at the SQL level for large environments
- Drag-and-Drop: Reorder rows to group related permissions together
- Excel Export: Full matrix export with AP-colored cells, rich-text badges, multi-AP notes, and AP columns next to users (matching the on-screen layout)
- Share Link: Copy a URL that preserves all active filters, user limit, and managed toggle
Filtering is split into two sections:
| Section | Fields | Applied |
|---|---|---|
| User Filters | All user attributes (department, job title, company, city, etc.) + User Tag | Server-side (full dataset) |
| Group Filters | Group name, membership type, Group Tag | Client-side (current page) |
Filters use a pill-based UI: click "+ Add filter" → select field → select value. Active filters appear as removable pills with inline value switching.
Column header filters: The Type and Tags columns have filter dropdowns in the column header. The Tags filter includes a "(Blank)" option to show only groups without any tags assigned.
Browse and manage all synced users with pagination.
- Tag Management: Create colored tags, assign/remove tags from selected users, bulk-tag all matching a filter
- Filtering: Same pill-based FilterBar with all user attribute columns + User Tag
- Text Search: Search by display name or UPN
- Selection: Checkbox selection with bulk tag operations
Browse and manage all synced groups with pagination.
- Tag Management: Create colored tags, assign/remove tags from selected groups, bulk-tag by filter
- Filtering: Pill-based FilterBar with all group attribute columns + Group Tag
- Text Search: Search by group name or description
- Selection: Checkbox selection with bulk tag operations
Browse all synced access packages with their catalog, assignment count, and category.
- Category Management: Create colored categories, assign a category to selected access packages, or set it directly via an inline dropdown per row
- Filtering: Filter by category (click a category pill) or show only uncategorized packages
- Text Search: Search by access package name or catalog name
- Selection: Checkbox selection with bulk category operations
Unlike tags (which allow multiple per entity), each access package can have only one category assigned. Categories drive the column ordering in the Matrix view.
View the last 50 sync operations from GraphSyncLog, showing timestamps, entity types, row counts, and durations.
Tags are user-defined labels (e.g. "VIP", "Contractors", "Finance Groups") that can be assigned to users or groups. They serve two purposes:
- Organization: Visually label entities in the Users/Groups tables
- Filtering: Use as filter criteria on any page (Users, Groups, or Matrix)
Tags are stored in the GraphTags and GraphTagAssignments SQL tables (auto-created on first use). Clicking a tag pill on the Users/Groups page adds it as a filter; it also appears as a "User Tag" or "Group Tag" option in the standard filter bar.
Categories are user-defined labels for access packages (e.g. "Identity", "Office 365", "Security"). Unlike tags, each access package can only have one category — this enforces clean grouping. Categories serve two purposes:
- Organization: Label access packages on the Access Packages page
- Matrix Column Ordering: AP columns in the Matrix view are sorted by category name first, then by assignment count within each category. Uncategorized APs appear at the end.
Categories are stored in the GraphCategories and GraphCategoryAssignments SQL tables (auto-created on first use). The GraphCategoryAssignments table has a primary key on accessPackageId, enforcing the single-category constraint.
All endpoints require Authorization: Bearer <JWT> unless auth is disabled (-NoAuth). The backend runs on port 3001 and serves the React SPA for non-API routes.
| Method | Path | Description |
|---|---|---|
GET |
/api/health |
Health check. Returns { status: "ok", mode: "sql"|"mock" } |
GET |
/api/auth-config |
Auth configuration for MSAL. Returns { enabled, clientId?, tenantId? } |
| Method | Path | Description |
|---|---|---|
GET |
/api/user-columns |
Column discovery for Matrix filters. Returns filterable columns from GraphUsers with up to 500 distinct values per column. Includes virtual __userTag and __groupTag columns if tags exist. |
GET |
/api/permissions |
Main matrix data. Returns permission assignments with all user attributes, access package mappings, and total user count. |
GET |
/api/access-package-groups |
Access package → group mapping with role names and assignment counts. |
GET |
/api/sync-log |
Recent sync log entries from GraphSyncLog. |
GET /api/permissions query parameters:
| Parameter | Type | Description |
|---|---|---|
userLimit |
int | Limit to top N users by assignment count. 0 = all users. |
filters |
JSON string | Server-side filters: {"department":"HR","__userTag":"VIP"} |
Response:
{
"data": [
{
"groupId": "uuid",
"groupDisplayName": "SG-Finance-Base",
"memberId": "uuid",
"memberDisplayName": "Jane Doe",
"membershipType": "Direct",
"department": "Finance",
"jobTitle": "Analyst",
"managedByAccessPackage": true
}
],
"totalUsers": 156,
"managedByPackages": [
{ "memberId": "uuid", "groupId": "uuid", "accessPackageIds": ["ap-001"] }
]
}GET /api/sync-log query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
limit |
int | 20 | Number of entries (max 100) |
| Method | Path | Description |
|---|---|---|
GET |
/api/user-columns-page |
Column discovery for Users page filters. Same format as /api/user-columns but scoped to the Users page. Includes __userTag virtual column. |
GET |
/api/users |
Paginated user list with tags. |
GET /api/users query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
search |
string | Search displayName or UPN (LIKE) | |
tagId |
int | Filter by tag ID (legacy, still supported) | |
limit |
int | 100 | Page size (max 500) |
offset |
int | 0 | Pagination offset |
filters |
JSON string | Attribute filters: {"department":"HR","__userTag":"VIP"} |
Response:
{
"data": [
{
"id": "uuid",
"displayName": "Jane Doe",
"userPrincipalName": "jane@contoso.com",
"department": "Finance",
"jobTitle": "Analyst",
"companyName": "Contoso",
"accountEnabled": true,
"tags": [{ "id": 1, "name": "VIP", "color": "#3b82f6" }]
}
],
"total": 1234
}| Method | Path | Description |
|---|---|---|
GET |
/api/group-columns |
Column discovery for Groups page filters. Includes __groupTag virtual column. |
GET |
/api/groups |
Paginated group list with tags. |
GET /api/groups query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
search |
string | Search displayName or description (LIKE) | |
tagId |
int | Filter by tag ID (legacy, still supported) | |
limit |
int | 100 | Page size (max 500) |
offset |
int | 0 | Pagination offset |
filters |
JSON string | Attribute filters: {"groupTypeCalculated":"Security","__groupTag":"Critical"} |
Response:
{
"data": [
{
"id": "uuid",
"displayName": "SG-Finance-Base",
"groupTypeCalculated": "Security",
"description": "Base access for Finance",
"tags": [{ "id": 2, "name": "Critical", "color": "#ef4444" }]
}
],
"total": 567
}| Method | Path | Description |
|---|---|---|
GET |
/api/tags?entityType=user|group |
List tags (optionally filtered by entity type). Returns name, color, assignment count. |
POST |
/api/tags |
Create a tag. Body: { name, color?, entityType }. Unique per (name, entityType). |
PATCH |
/api/tags/:id |
Update tag name and/or color. Body: { name?, color? } |
DELETE |
/api/tags/:id |
Delete tag and all its assignments (cascade). |
POST |
/api/tags/:id/assign |
Assign tag to specific entities. Body: { entityIds: ["uuid", ...] } |
POST |
/api/tags/:id/unassign |
Remove tag from specific entities. Body: { entityIds: ["uuid", ...] } |
POST |
/api/tags/:id/assign-by-filter |
Bulk-assign tag to all entities matching a search/filter. Body: { entityType, search?, filters? } |
| Method | Path | Description |
|---|---|---|
GET |
/api/categories |
List all categories with assignment count. |
POST |
/api/categories |
Create a category. Body: { name, color? }. Name must be unique. |
PATCH |
/api/categories/:id |
Update category name and/or color. Body: { name?, color? } |
DELETE |
/api/categories/:id |
Delete category and all its assignments (cascade). |
POST |
/api/categories/:id/assign |
Assign category to an access package (replaces any existing category). Body: { accessPackageId } |
POST |
/api/categories/unassign |
Remove the category from an access package. Body: { accessPackageId } |
GET |
/api/category-assignments |
All category assignments as flat list (used by Matrix for column ordering). |
| Method | Path | Description |
|---|---|---|
GET |
/api/access-packages |
Paginated access package list with category info. |
GET /api/access-packages query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
search |
string | Search displayName or catalog name (LIKE) | |
categoryId |
int | Filter by category ID | |
uncategorized |
string | Set to true to show only uncategorized packages |
|
limit |
int | 100 | Page size (max 500) |
offset |
int | 0 | Pagination offset |
The UI uses a hybrid filtering approach for optimal performance:
┌─────────────────────────────────────────────────┐
│ Frontend (React) │
│ │
│ activeFilters: [{field, value}, ...] │
│ │ │
│ ├── User attribute filters ──────────► Server-side (SQL WHERE)
│ │ (department, jobTitle, __userTag) │
│ │ │
│ └── Relationship filters ──────────────► Client-side (JS filter)
│ (groupDisplayName, membershipType) │
│ │
│ Column discovery: │
│ /api/user-columns → full dataset values │
│ Data rows → current page values │
│ │
│ Debounced fetch (400ms) on filter change │
└─────────────────────────────────────────────────┘
Server-side filters (applied in SQL) are more efficient for large datasets — they reduce data before it reaches the browser. These include all columns from GraphUsers plus the virtual __userTag and __groupTag tag columns.
Client-side filters are applied in JavaScript after data is loaded. These include relationship-level fields like membershipType and groupDisplayName that come from the permission view rather than the users table.
All filters use parameterized SQL queries to prevent injection. Virtual tag columns (__userTag, __groupTag) are extracted from the filters object and translated to tag table subqueries before the main query runs.
The Role Mining UI covers the most common analysis scenarios visually. For advanced or custom queries, FortigiGraph's SQL views give you full flexibility.
The Problem: In Entra ID, you can't easily see the gap between what users should have (access package assignments) and what they actually have (direct group memberships).
What FortigiGraph Gives You:
The Matrix View's IST/SOLL toggle shows this visually. For custom analysis:
-- Find users with DIRECT group memberships when they should only have access through packages
SELECT * FROM vw_UnmanagedPermissions;Use Cases:
- Identify "backdoor" access that bypasses governance
- Clean up direct assignments that should be managed by access packages
- Audit compliance with access governance policies
The Problem: Entra ID doesn't show you aggregate views of who has what through access packages, which packages are most used, or how assignments have changed over time.
-- Complete view of user permissions via access packages
SELECT * FROM vw_UserPermissionAssignmentViaAccessPackage;
-- All permission assignments (direct, indirect, eligible, owner)
SELECT * FROM vw_UserPermissionAssignments;The Problem: Entra ID doesn't provide aggregate statistics on how long access requests take to approve.
-- Approval response times with buckets (< 1 hour, 1-4 hours, etc.)
SELECT * FROM vw_ApprovedRequestTimeline;
-- Find pending requests and how long they've been waiting
SELECT * FROM vw_PendingRequestTimeline WHERE hoursPending > 24;
-- Aggregate approval statistics
SELECT * FROM vw_RequestResponseMetrics;The Problem: Entra ID shows individual review results, but doesn't aggregate patterns or completion rates.
-- Access package last review details
SELECT * FROM vw_AccessPackageLastReview;
-- Denied request patterns
SELECT * FROM vw_DeniedRequestTimeline;The Problem: You can't easily see which memberships are managed through governance vs direct assignment.
-- Complete membership analysis: Owner, Direct, Indirect, Eligible
SELECT * FROM vw_UserPermissionAssignments
WHERE memberId = 'user-guid-here';
-- Recursive group memberships with full paths
SELECT * FROM vw_GraphGroupMembersRecursive
WHERE groupId = 'group-guid-here'
ORDER BY depth;The Problem: Entra ID only shows current state. You can't answer "who had access on this date?"
-- Who had access to a specific group on January 15th?
SELECT * FROM GraphGroupMembers
FOR SYSTEM_TIME AS OF '2025-01-15 10:00:00'
WHERE groupId = 'your-group-id';
-- Track all changes for a specific user
SELECT userPrincipalName, department, ValidFrom, ValidTo
FROM GraphUsers FOR SYSTEM_TIME ALL
WHERE userPrincipalName = 'john.doe@contoso.com'
ORDER BY ValidFrom DESC;- Guided Setup:
New-FGConfigwizard creates all Azure resources and config in one go - Easy Authentication: Service principal and interactive auth with automatic token refresh
- Azure SQL Integration: Temporal tables with automatic version history tracking
- High-Performance Sync: SqlBulkCopy-based operations (20-50x faster than row-by-row)
- Parallel Execution: Sync up to 6 entity types concurrently
- Role Mining UI: Interactive web application for visual permission analysis
- Users: All user properties including custom/extension attributes
- Groups: Group details with security, type, and organization info
- Memberships: Direct, transitive, PIM eligible, and owner relationships
- Access Packages: Catalogs, packages, assignments, policies, requests, reviews
- Automatic Schema Evolution: Add new columns without recreating tables
FortigiGraph creates SQL views automatically for instant insights:
Group Membership Views (via Initialize-FGGroupMembershipViews):
vw_GraphGroupMembersRecursive- All memberships (direct + indirect) with pathsvw_UserPermissionAssignments- Comprehensive view with all types as separate rows: Owner, Direct, Indirect, Eligible (a user can have multiple types per group, e.g. Direct + Owner)
Access Package Views (via Initialize-FGAccessPackageViews):
vw_UserPermissionAssignmentViaAccessPackage- User permissions via access packagesvw_DirectGroupMemberships- Direct group membershipsvw_DirectGroupOwnerships- Direct group ownershipsvw_UnmanagedPermissions- IST vs SOLL gapsvw_AccessPackageAssignmentDetails- Assignment detailsvw_AccessPackageLastReview- Last review per packagevw_ApprovedRequestTimeline- Approval times with response bucketsvw_DeniedRequestTimeline- Denied request analysisvw_PendingRequestTimeline- Aging pending requestsvw_RequestResponseMetrics- Aggregate approval statistics
- Azure Automation: One-command setup with
New-FGAzureAutomationAccount - Config-Driven: All settings in one JSON file
- Secure Credentials: Encrypted credential storage using Windows DPAPI
- Comprehensive Logging: Sync statistics logged to
GraphSyncLogtable
The config file drives all FortigiGraph operations. Create one with New-FGConfig or manually from the template in Config/tenantname.json.template.
{
"Azure": {
"TenantId": "yourtenant.onmicrosoft.com",
"SubscriptionId": "your-subscription-id",
"ResourceGroupName": "rg-fortigraph",
"Location": "northeurope",
"SQLServerName": "sql-fortigraph-xxxxx",
"DatabaseName": "GraphData",
"AdminUsername": "sqladmin",
"AdminUserPassword_Encrypted": "...",
"AutomationAccountName": "aa-fortigraph-xxxxx"
},
"Graph": {
"TenantId": "yourtenant.onmicrosoft.com",
"ClientId": "your-app-client-id",
"ClientSecret_Encrypted": "..."
},
"Sync": {
"Users": { "Enabled": true, "TableName": "GraphUsers", "AdditionalAttributes": [] },
"Groups": { "Enabled": true, "TableName": "GraphGroups" },
"GroupMembers": { "Enabled": true, "TableName": "GraphGroupMembers" },
"GroupEligibleMembers": { "Enabled": true },
"GroupOwners": { "Enabled": true, "TableName": "GraphGroupOwners" },
"Catalogs": { "Enabled": true, "TableName": "GraphCatalogs" },
"AccessPackages": { "Enabled": true },
"AccessPackageAssignments": { "Enabled": true },
"AccessPackageResourceRoleScopes": { "Enabled": true },
"AccessPackageAssignmentPolicies": { "Enabled": true },
"AccessPackageAssignmentRequests": { "Enabled": true },
"AccessPackageAccessReviews": { "Enabled": true },
"Views": { "Enabled": true },
"ParallelExecution": true
}
}# Using config file (recommended)
Get-FGAccessToken -ConfigFile '.\Config\mycompany.json'
# Or with explicit parameters
Get-FGAccessToken -TenantId "contoso.onmicrosoft.com" -ClientId "app-client-id" -ClientSecret "secret"Get-FGAccessTokenInteractive -TenantId "contoso.onmicrosoft.com" -ClientId "app-client-id"| Permission | Purpose |
|---|---|
User.Read.All |
Read all users |
Group.Read.All |
Read all groups |
GroupMember.Read.All |
Read group memberships |
Directory.Read.All |
Read directory data |
EntitlementManagement.Read.All |
Read access packages, catalogs, assignments |
AccessReview.Read.All |
Read access review decisions |
AuditLog.Read.All |
Read sign-in activity (used by user sync) |
New-FGConfig sets up all these permissions automatically when creating a new App Registration.
# Sync everything based on config file settings
Start-FGSync -ConfigFile '.\Config\mycompany.json'Start-FGSync handles:
- Authentication (always gets a fresh token)
- SQL connection with firewall management
- Parallel execution of all enabled sync types
- Performance index creation
- Analytical view creation
- Summary report with statistics
# Users (with extra attributes)
Sync-FGUser -AdditionalAttributes @('officeLocation', 'city', 'employeeType')
# Groups
Sync-FGGroup
# Memberships
Sync-FGGroupMember # Direct memberships
Sync-FGGroupTransitiveMember # Transitive (includes nested)
Sync-FGGroupEligibleMember # PIM eligible memberships
Sync-FGGroupOwner # Group owners
# Access Packages
Sync-FGCatalog
Sync-FGAccessPackage
Sync-FGAccessPackageAssignment
Sync-FGAccessPackageResourceRoleScope
Sync-FGAccessPackageAssignmentPolicy
Sync-FGAccessPackageAssignmentRequest
Sync-FGAccessPackageAccessReviewAdd new attributes without recreating the table:
# First run - default attributes
Sync-FGUser
# Later - add new attributes (columns added automatically)
Sync-FGUser -AdditionalAttributes @('employeeType', 'officeLocation')# Connect using config file
Connect-FGSQLServer -ConfigFile '.\Config\mycompany.json'
# List tables
Get-FGSQLTable
# Query data
Invoke-FGSQLQuery -Query "SELECT * FROM GraphUsers WHERE department = 'IT'"
$count = Invoke-FGSQLQuery -Query "SELECT COUNT(*) FROM GraphUsers" -AsScalar
# Clear table (preserves history)
Clear-FGSQLTable -TableName "GraphUsers_Test"
# Clear table and history
Clear-FGSQLTable -TableName "GraphUsers_Test" -DeleteHistory -ForceAll synced data uses SQL Server temporal tables for automatic change tracking.
-- Current data
SELECT * FROM GraphUsers;
-- Point-in-time query
SELECT * FROM GraphUsers FOR SYSTEM_TIME AS OF '2025-06-15 10:00:00';
-- All history
SELECT * FROM GraphUsers FOR SYSTEM_TIME ALL
WHERE userPrincipalName = 'john@contoso.com'
ORDER BY ValidFrom DESC;
-- Changes in the last 30 days
SELECT * FROM GraphGroupMembers FOR SYSTEM_TIME ALL
WHERE ValidFrom >= DATEADD(DAY, -30, GETDATE())
ORDER BY ValidFrom DESC;Set up automated scheduled syncs with a single command:
New-FGAzureAutomationAccount -ConfigFile '.\Config\mycompany.json'What it creates:
- Azure Automation Account
- Encrypted variables for Graph and SQL credentials
- Runbooks for each sync type (Users, Groups, Members, Catalogs, Access Packages, etc.)
- Daily schedules (optional, configurable time zone)
- SQL firewall rule for Azure services
Memory considerations: Azure Automation sandbox has a 400 MB limit. Group member sync uses -UseBatching mode for constant memory usage.
Post-setup:
- Import FortigiGraph module via Azure Portal > Automation Account > Modules > Browse Gallery
- Wait for module imports to complete
- Test runbooks manually before enabling schedules
Discover and document attribute mappings across your identity infrastructure:
# Get all apps with provisioning configured
$apps = Get-FGServicePrincipalWithSync -IncludeSchema
# Extract attribute mappings
$mappings = Get-FGAttributeMapping -ServicePrincipalWithSync $apps
# Export for analysis
$mappings | Export-Csv -Path "attribute-mappings.csv" -NoTypeInformationWorks with HR provisioning (Workday, SuccessFactors), Azure AD Connect Cloud Sync, and SCIM applications.
FortigiGraph/
├── Functions/ # All PowerShell functions
│ ├── Base/ # Authentication & HTTP operations (20 functions)
│ ├── Generic/ # Graph API wrappers (49 functions)
│ ├── Specific/ # Business logic helpers (9 functions)
│ ├── SQL/ # Azure SQL operations (24 functions)
│ ├── Sync/ # Data synchronization (14 functions)
│ └── Automation/ # Azure Automation management (4 functions)
├── UI/ # Role Mining Web Application
│ ├── backend/ # Node.js + Express API server
│ └── frontend/ # React + Vite + Tailwind
├── Config/ # Configuration templates
│ └── tenantname.json.template
├── _Build/ # Build and publishing scripts
├── _Test/ # Testing scripts and documentation
├── FortigiGraph.psm1 # Module entry point
├── FortigiGraph.psd1 # Module manifest
└── README.md
Total: 120 functions
$Global:DebugMode = 'G' # GET requests
$Global:DebugMode = 'P' # POST/PATCH requests
$Global:DebugMode = 'D' # DELETE requests
$Global:DebugMode = 'T' # Token operations
$Global:DebugMode = 'GP' # Multiple categories| Issue | Solution |
|---|---|
| SQL connection fails | Connect-FGSQLServer -ConfigFile config.json (updates firewall automatically) |
| "No Access Token found" | Run Get-FGAccessToken -ConfigFile config.json |
| Permission errors after changing app | Start-FGSync always gets a fresh token; for manual use run Get-FGAccessToken again |
| Temporal table schema error | Don't modify tables directly; use Sync-FG* functions which handle schema changes |
| Can't truncate temporal table | Use DELETE or Clear-FGSQLTable instead of TRUNCATE |
- PowerShell: 5.1 or later (7+ recommended)
- Azure: Subscription with Contributor access
- Modules:
AzPowerShell module (Install-Module Az -Scope CurrentUser) - Permissions: See Required Permissions table
- GitHub Issues: Report bugs or request features
- Examples: Check
_Test/folder andConfig/tenantname.json.template
MIT License - see LICENSE for details.
Author: Wim van den Heijkant | Company: Fortigi GitHub: github.com/Fortigi/FortigiGraph | PowerShell Gallery: FortigiGraph