Templates/Plugins/Invoke-PluginInfiniteCampusStudents.ps1
|
#Infinite Campus Student Plugin — IDBridge plugin template # TemplateVersion: 1 <# Shipped with the IDBridge module and copied to <RootPath>\Plugins by Install-IDBridge. A minimal starting point for pulling students from the Infinite Campus OneRoster API. Edit every placeholder value (API URLs, domain, OUs, building map, grade settings) for your district before enabling the plugin in IDBridgeConfig.psd1 — it throws until the API URL is set. The OneRoster connection is created in Infinite Campus under System Settings > Digital Learning Applications ("Add Application" > browse for a generic OneRoster connection). Use the OneRoster 1.2 rostering base URL and note that Infinite Campus does not expose food-service PINs or password words via OneRoster, so the password types available here are RANDOM and API-PASSPHRASE. #> <# Secrets are stored in the IDBridge secret vault (encrypted files under <RootPath>\Vault). Add/change them with: Set Passphrase API Key if in Use Set-IDBridgeSecret -Name 'ApiKey-Passphrase' Set Passphrase Nonce for students if in Use Set-IDBridgeSecret -Name 'ApiKey-PassphraseNonceStudent' Set Infinite Campus OneRoster client secret Set-IDBridgeSecret -Name 'ApiKey-InfiniteCampus' #> function Invoke-PluginInfiniteCampusStudents { [CmdletBinding()] param () $PersonTypeGeneric = "Students" #Generic Config $DomainName = "my.yourdistrict.org" $Company = "Your School District" #Student Specific Config $DaysLastSeen = 14 # Number of days since last seen to consider a user "orphaned" and eligible for deactivation $DaysLastSeenGraduate = 90 # Grade-12 students who disappear (graduates) keep their accounts this long instead #AD Config $ADUserRootOU = "OU=YourDistrict,DC=yourdomain,DC=local" #Google Config $GoogleUserRootOU = "/YourDistrict" # InfiniteCampus Config — URLs from your district's Infinite Campus OneRoster connection $InfiniteCampusConfig = @{ BaseUrl = "https://yourdistrictwi.infinitecampus.org/campus/api/oneroster/v1p2/yourdistrict/ims/oneroster/rostering/v1p2" TokenUrl = "https://yourdistrictwi.infinitecampus.org/campus/oauth2/token?appName=yourdistrict" ClientId = 'your-client-id' ExcludeSchoolIdentifiers = @() # School identifiers (school numbers) to skip entirely, e.g. @('0210') SafetyCheckCount = 100 # Your expected student count — the run aborts if the API returns fewer than SafetyCheckPercentage% of this SafetyCheckPercentage = 75 } if ($InfiniteCampusConfig.BaseUrl -like "*yourdistrict*") { Throw "Invoke-PluginInfiniteCampusStudents: this plugin template still has placeholder values. Edit $($PSCommandPath) for your district before enabling it." } # Infinite Campus client secret - always required for the OneRoster API call try { $infiniteCampusClientSecret = Get-IDBridgeSecret -Name 'ApiKey-InfiniteCampus' } catch { Throw "Error retrieving the Infinite Campus client secret from the vault: $($_)" } $InfiniteCampusConfig.ClientSecret = $infiniteCampusClientSecret # Map your Infinite Campus school identifiers (school numbers) to friendly names and short codes ('000' is the fallback) $BuildingIDMapping = @{ '0110' = @{ Name = 'Sample Elementary'; Code = 'SE' } '0410' = @{ Name = 'High School'; Code = 'HS' } '000' = @{ Name = 'Your School District'; Code = 'YSD' } # fallback } ############ # Per-grade settings — add/remove grades as needed # Password Type Must be API-PASSPHRASE or RANDOM (Infinite Campus OneRoster has no FSPIN/WORD source) # Define defaults once $GradeDefaultSettings = @{ Provision = $true AD = @{ Provision = $true passPrefix = 'Temp' ChangePasswordAtLogon = $false PasswordType = 'RANDOM' } Google = @{ Provision = $true passPrefix = 'Temp' ChangePasswordAtLogon = $false PasswordType = 'RANDOM' } } # Only define grades that differ from defaults $GradeOverrides = @{ # Vanished grade-12s are relabeled GD below and parked in the Grade-GD OU for the # rest of their $DaysLastSeenGraduate window. To deactivate them immediately # instead, disable GD provisioning: # 'GD' = @{ # Provision = $false # AD = @{ Provision = $false } # Google = @{ Provision = $false } # } # Example: younger grades Google-only # 'KG' = @{ AD = @{ Provision = $false } } # Example: if 9-12 used passphrases instead # '09' = @{ AD = @{ PasswordType = 'API-PASSPHRASE' }; Google = @{ PasswordType = 'API-PASSPHRASE' } } } # All valid grades $ValidGrades = @('PK','K4','KG','01','02','03','04','05','06','07','08','09','10','11','12','GD') # Build the merged settings $GradeSettings = @{} foreach ($grade in $ValidGrades) { $merged = @{ Provision = $GradeDefaultSettings.Provision AD = $GradeDefaultSettings.AD.Clone() Google = $GradeDefaultSettings.Google.Clone() } if ($GradeOverrides.ContainsKey($grade)) { $override = $GradeOverrides[$grade] if ($override.ContainsKey('Provision')) { $merged.Provision = $override.Provision } foreach ($key in @('AD','Google')) { if ($override.ContainsKey($key)) { foreach ($prop in $override[$key].Keys) { $merged[$key][$prop] = $override[$key][$prop] } } } } $GradeSettings[$grade] = $merged } ############ #Passphrase API Configuration - only fetch the secrets when a grade actually uses the passphrase API $usePassphraseAPI = [bool]($GradeSettings.Values | Where-Object { $_.AD.PasswordType -eq 'API-PASSPHRASE' -or $_.Google.PasswordType -eq 'API-PASSPHRASE' }) $PassphraseAPI = @{ Nonce = if ($usePassphraseAPI) { Get-IDBridgeSecret -Name 'ApiKey-PassphraseNonceStudent' } #Shared secret nonce Mode = "WORDS" #Must be WORDS or VERBNOUN WordCount = 3 #How many words the passphrase will have. Only applies to WORDS mode AuthToken = if ($usePassphraseAPI) { Get-IDBridgeSecret -Name 'ApiKey-Passphrase' } #API Auth token in SecureString Format } try { $params = @{ BaseUrl = $InfiniteCampusConfig.BaseUrl TokenUrl = $InfiniteCampusConfig.TokenUrl ClientId = $InfiniteCampusConfig.ClientId ClientSecret = $(ConvertFrom-SecureString $InfiniteCampusConfig.ClientSecret -AsPlainText) ExcludeSchoolIdentifiers = $InfiniteCampusConfig.ExcludeSchoolIdentifiers SafetyCheckCount = $InfiniteCampusConfig.SafetyCheckCount SafetyCheckPercentage = $InfiniteCampusConfig.SafetyCheckPercentage } $data = Get-SourceDataInfiniteCampus @params } catch { Throw "Error retrieving data from Infinite Campus: $($_)" } $dataNormalized = foreach ($item in $data) { #[string] coercion: a tobedeleted student comes back with no roles/grades, and a #$null would throw on the hashtable lookups below ('' falls through to the #grade-not-found skip / building fallback instead) $Grade = $null $Grade = [string]$item.Grade $building = $null $schoolID = [string]$item.SchoolIdentifier $building = if ($BuildingIDMapping.$($schoolID).Name) { $BuildingIDMapping.$($schoolID).Name} else {$BuildingIDMapping."000".Name } # --- IsActive Property --- $isActive = $null #Check LastSeen Date against threshold for orphaned accounts #Grade-12 students who disappear from the feed are (almost always) graduates — they get #the longer $DaysLastSeenGraduate window so accounts survive into the summer $daysLastSeenWindow = if ($Grade -eq '12') { $DaysLastSeenGraduate } else { $DaysLastSeen } if (([datetime]$item.LastSeen) -ge (Get-Date).AddDays(-$($daysLastSeenWindow))) { $isActive = $true } else { $isActive = $false } #A grade-12 past the normal window has graduated (or left) — relabel to GD so the #record lands in the Grade-GD OU/groups for the rest of the grace window if ($Grade -eq '12' -and ([datetime]$item.LastSeen) -lt (Get-Date).AddDays(-$($DaysLastSeen))) { $Grade = 'GD' } #Check the Infinite Campus status/account flags — a withdrawn or disabled student #should not wait out the DaysLastSeen window if ($item.Status -ne 'active') { $isActive = $false } if ("$($item.ActiveUserAccount)" -ne 'true') { $isActive = $false } #An enrollment end date in the past means the student has left if ($item.RoleEndDate -and ([datetime]$item.RoleEndDate) -lt (Get-Date).Date) { $isActive = $false } #Check if Grade is in configuration and enabled if (-not $GradeSettings.$($Grade)) { $isActive = $false Write-Log -Message ("Student: Grade " + $Grade + " not found in configuration file for PersonID: " + $item.LocalID + ". Disabling user from processing.") -Level Trace Continue } if ($isActive -eq $true) { $isActive = $GradeSettings.$($Grade).Provision } #Groups # Determine automatic groups based on the student's building and grade # The optional Get-CustomStudentGroups helper (bottom of this file) encodes your # district's group policy. $groupsAutomatic = $null $groupsAutomatic = if (Test-Path Function:\Get-CustomStudentGroups) { Get-CustomStudentGroups -building $building -grade $Grade } #Proposed Groups $proposedGroupList = @() if ($groupsAutomatic) {$proposedGroupList += $groupsAutomatic} #AD Key Setup $keyAD = $null if ($gradeSettings.$($grade).AD.PasswordType -ne "API-PASSPHRASE") { $keyAD = "$((New-Guid).Guid)" } #Key Setup Google $keyGoogle = $null if ($gradeSettings.$($grade).Google.PasswordType -ne "API-PASSPHRASE") { $keyGoogle = "$((New-Guid).Guid)" } # --- Build the standardized source record via the module factory --- $recordFields = @{ PersonID = $item.LocalID NameFirst = Format-IDBridgeName $item.NameFirst NameLast = Format-IDBridgeName $item.NameLast Username = $item.LocalID Building = $building JobTitle = "Student - Grade $($Grade)" Company = $Company Department = "Grade-$($Grade)" UPN = "$($item.LocalID)@$($DomainName)" PersonTypeID = "1" InternalID = $item.InternalID IDBActive = $isActive GroupsProposed = ($proposedGroupList | Select-Object -Unique) PersonType = "Student - Grade $($Grade)" ProvisionAD = [bool]$GradeSettings.$($Grade).AD.Provision ADOrganizationalUnit = "OU=Grade-$($Grade),OU=$($PersonTypeGeneric),$($ADUserRootOU)" ADOrganizationalUnitTrash = "OU=$(Get-Date -Format yyyy),OU=$($PersonTypeGeneric),OU=Trash,$($ADUserRootOU)" ADChangePasswordAtLogon = $GradeSettings.$($Grade).AD.ChangePasswordAtLogon ADPassphraseAPI = if ($gradeSettings.$($grade).AD.PasswordType -eq "API-PASSPHRASE") { $PassphraseAPI } else { $null } ADKey = if ($keyAD) { ConvertTo-SecureString -String $keyAD -AsPlainText -Force } else { $null } ProvisionGoogle = [bool]$GradeSettings.$($Grade).Google.Provision GoogleOrganizationalUnit = "$($GoogleUserRootOU)/$($PersonTypeGeneric)/Grade-$($Grade)" GoogleOrganizationalUnitTrash = "/Trash/$($PersonTypeGeneric)/$(Get-Date -Format yyyy)" GoogleChangePasswordAtLogon = $GradeSettings.$($Grade).Google.ChangePasswordAtLogon GooglePassphraseAPI = if ($gradeSettings.$($grade).Google.PasswordType -eq "API-PASSPHRASE") { $PassphraseAPI } else { $null } GoogleKey = if ($keyGoogle) { ConvertTo-SecureString -String $keyGoogle -AsPlainText -Force } else { $null } } try { New-IDBridgeSourceRecord @recordFields } catch { Throw "Error creating source record for PersonID: $($item.LocalID) - $($item.NameFirst) $($item.NameLast) $($_)" } } return $dataNormalized } <# Sample Data Returned by Get-SourceDataInfiniteCampus (fabricated example) SourcedId : FF7E5C0C-BA84-408A-AE42-FBAB90041547 Status : active DateLastModified : 8/25/2025 8:35:00 PM LocalID : 200001 InternalID : 10856 ActiveUserAccount : true NameFirst : SAMPLE NameLast : STUDENT Email : 200001@my.yourdistrict.org Role : student RoleType : primary RoleBeginDate : 2025-09-02 RoleEndDate : Grade : 06 SchoolName : SAMPLE MIDDLE SCHOOL SchoolIdentifier : 0210 LastSeen : 2026-04-22 22:07:21 #> function Get-CustomStudentGroups() { [cmdletbinding()] Param( [parameter(Mandatory=$true)] $building, [parameter(Mandatory=$true)] $grade ) # OPTIONAL helper — encode your district's student group policy here (or delete the # function; the plugin only calls it when it exists). #Initialize Group Variable $groups = @() #Building Short Name for Groups — buildings in this map get a "<code>_Students" group $buildingShortName = @{ "Sample Elementary" = "SE" "High School" = "HS" } #All Students ######------------###### $groups += ("Students") ######------------###### #All Building Students (ex. "HS_Students") ######------------###### if ($buildingShortName.$building) { $groups += ($buildingShortName.$building + "_Students") } ######------------###### #Grade Level Groups ######------------###### $groups += ("Grade-" + $grade) ######------------###### return $groups } |