<# .SYNOPSIS Sets up SynaMark in your Microsoft 365 tenant. Run once, as a SharePoint Administrator who can also consent to Microsoft Graph permissions. .DESCRIPTION Everything this script creates lives in YOUR tenant. Nothing is sent to KamSoft Consultants. It: 1. Creates the SharePoint communication site /sites/SynaMark. 2. Gives all internal users read access (needed so Outlook can read the published signature configuration) and gives your SynaMark Admins and SynaMark Editors groups owner/edit access. 3. Grants the SynaMark application access to THIS SITE ONLY (Microsoft Graph "Sites.Selected"), so SynaMark never holds tenant-wide SharePoint permissions. 4. Publishes a starter configuration and creates the assets folder. 5. Optionally assigns your groups to the SynaMark Administrator and Editor app roles. The elevated permissions used here (Sites.FullControl.All, Group.Read.All, AppRoleAssignment.ReadWrite.All) are granted to Microsoft's own "Microsoft Graph Command Line Tools" app for the duration of your session - never to SynaMark. .PARAMETER TenantName The first part of your onmicrosoft.com domain, e.g. "contoso" for contoso.onmicrosoft.com. .PARAMETER AdminsGroup Display name or object ID of the Entra group whose members administer SynaMark. .PARAMETER EditorsGroup Display name or object ID of the Entra group whose members design signatures and campaigns. .PARAMETER OrganizationName Name shown in the starter signature brand settings. .PARAMETER SynaMarkAppId Application (client) ID of the SynaMark app. Defaults to the published SynaMark app. .EXAMPLE ./Install-SynaMark.ps1 -TenantName contoso -AdminsGroup "SynaMark Admins" -EditorsGroup "SynaMark Editors" -OrganizationName "Contoso Ltd" .NOTES Requires: PowerShell 7+, Microsoft.Online.SharePoint.PowerShell, Microsoft.Graph.Authentication. Install-Module Microsoft.Online.SharePoint.PowerShell, Microsoft.Graph.Authentication -Scope CurrentUser #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] [ValidatePattern('^[a-z0-9-]{1,60}$')] [string] $TenantName, [Parameter(Mandatory)] [string] $AdminsGroup, [Parameter(Mandatory)] [string] $EditorsGroup, [Parameter(Mandatory)] [ValidateLength(1, 120)] [string] $OrganizationName, [ValidatePattern('^[0-9a-fA-F-]{36}$')] [string] $SynaMarkAppId = '00000000-0000-0000-0000-000000000000', [switch] $SkipAppRoles ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest if ($SynaMarkAppId -eq '00000000-0000-0000-0000-000000000000') { throw 'Pass -SynaMarkAppId (the SynaMark application ID shown in your onboarding email or the SynaMark portal).' } $siteUrl = "https://$TenantName.sharepoint.com/sites/SynaMark" $adminUrl = "https://$TenantName-admin.sharepoint.com" $here = Split-Path -Parent $MyInvocation.MyCommand.Path function Step($n, $text) { Write-Host "`n[$n/6] $text" -ForegroundColor Cyan } # ---------------------------------------------------------------- connect Step 1 'Connecting to SharePoint Online and Microsoft Graph' Import-Module Microsoft.Online.SharePoint.PowerShell -UseWindowsPowerShell -ErrorAction Stop -WarningAction SilentlyContinue Import-Module Microsoft.Graph.Authentication -ErrorAction Stop Connect-SPOService -Url $adminUrl $graphScopes = @('Sites.FullControl.All', 'Group.Read.All') if (-not $SkipAppRoles) { $graphScopes += 'AppRoleAssignment.ReadWrite.All', 'Application.Read.All' } Connect-MgGraph -Scopes $graphScopes -NoWelcome $ctx = Get-MgContext $tenantId = $ctx.TenantId function Resolve-Group([string] $nameOrId) { if ($nameOrId -match '^[0-9a-fA-F-]{36}$') { $g = Invoke-MgGraphRequest GET "/v1.0/groups/$($nameOrId)?`$select=id,displayName,groupTypes,securityEnabled" } else { $escaped = $nameOrId.Replace("'", "''") $res = Invoke-MgGraphRequest GET "/v1.0/groups?`$filter=displayName eq '$escaped'&`$select=id,displayName,groupTypes,securityEnabled" if ($res.value.Count -ne 1) { throw "Expected exactly one group named '$nameOrId', found $($res.value.Count)." } $g = $res.value[0] } # SharePoint claims differ for Microsoft 365 groups and security groups. $claim = if ($g.groupTypes -contains 'Unified') { "c:0o.c|federateddirectoryclaimprovider|$($g.id)" } else { "c:0t.c|tenant|$($g.id)" } [pscustomobject]@{ Id = $g.id; Name = $g.displayName; Claim = $claim } } $admins = Resolve-Group $AdminsGroup $editors = Resolve-Group $EditorsGroup # ---------------------------------------------------------------- site Step 2 "Creating $siteUrl" $existing = $null try { $existing = Get-SPOSite -Identity $siteUrl -ErrorAction Stop } catch { Write-Verbose "Site not found yet: $($_.Exception.Message)" } if ($existing) { Write-Host ' Site already exists; reusing it.' } elseif ($PSCmdlet.ShouldProcess($siteUrl, 'Create communication site')) { New-SPOSite -Url $siteUrl -Title 'SynaMark' -Owner $ctx.Account -Template 'SITEPAGEPUBLISHING#0' -StorageQuota 1024 -LocaleId 1033 } # The configuration is read by everyone but must not be shared outside the organisation. Set-SPOSite -Identity $siteUrl -SharingCapability Disabled -DenyAddAndCustomizePages $true # ---------------------------------------------------------------- permissions Step 3 'Setting site permissions' $groups = Get-SPOSiteGroup -Site $siteUrl $owners = ($groups | Where-Object { $_.Roles -contains 'Full Control' } | Select-Object -First 1).Title $members = ($groups | Where-Object { $_.Roles -contains 'Edit' } | Select-Object -First 1).Title $visitors = ($groups | Where-Object { $_.Roles -contains 'Read' } | Select-Object -First 1).Title Add-SPOUser -Site $siteUrl -Group $owners -LoginName $admins.Claim | Out-Null Add-SPOUser -Site $siteUrl -Group $members -LoginName $editors.Claim | Out-Null # "Everyone except external users": read-only, so every user's Outlook can read the published config. Add-SPOUser -Site $siteUrl -Group $visitors -LoginName "c:0-.f|rolemanager|spo-grid-all-users/$tenantId" | Out-Null Write-Host " Owners: $($admins.Name) | Members: $($editors.Name) | Visitors: Everyone except external users" # ---------------------------------------------------------------- Sites.Selected grant Step 4 'Granting SynaMark access to this site only (Sites.Selected)' $site = Invoke-MgGraphRequest GET "/v1.0/sites/$TenantName.sharepoint.com:/sites/SynaMark?`$select=id" $siteId = $site.id $perms = Invoke-MgGraphRequest GET "/v1.0/sites/$siteId/permissions" $already = $perms.value | Where-Object { $_.grantedToIdentitiesV2.application.id -eq $SynaMarkAppId -or $_.grantedToIdentities.application.id -eq $SynaMarkAppId } if ($already) { Write-Host ' Grant already present.' } else { # "write" lets SynaMark Admins/Editors save from the portal; each user's own # SharePoint permissions still apply (everyone else can only read). $body = @{ roles = @('write'); grantedToIdentities = @(@{ application = @{ id = $SynaMarkAppId; displayName = 'SynaMark' } }) } | ConvertTo-Json -Depth 5 Invoke-MgGraphRequest POST "/v1.0/sites/$siteId/permissions" -Body $body -ContentType 'application/json' | Out-Null } # ---------------------------------------------------------------- starter config Step 5 'Publishing the starter configuration' $configPath = 'config/published.json' $exists = $true try { Invoke-MgGraphRequest GET "/v1.0/sites/$siteId/drive/root:/$($configPath)?`$select=id" | Out-Null } catch { $exists = $false } if ($exists) { Write-Host ' A configuration already exists; leaving it unchanged.' } else { $starter = Get-Content -Raw -Path (Join-Path $here 'starter-config.json') | ConvertFrom-Json $starter.brand.name = $OrganizationName $starter.publishedAt = (Get-Date).ToUniversalTime().ToString('o') $starter.publishedBy = $ctx.Account $json = $starter | ConvertTo-Json -Depth 20 Invoke-MgGraphRequest PUT "/v1.0/sites/$siteId/drive/root:/$($configPath):/content" -Body $json -ContentType 'application/json' | Out-Null Invoke-MgGraphRequest PUT "/v1.0/sites/$siteId/drive/root:/config/draft.json:/content" -Body $json -ContentType 'application/json' | Out-Null } $folder = @{ name = 'assets'; folder = @{}; '@microsoft.graph.conflictBehavior' = 'replace' } | ConvertTo-Json Invoke-MgGraphRequest POST "/v1.0/sites/$siteId/drive/root/children" -Body $folder -ContentType 'application/json' | Out-Null # ---------------------------------------------------------------- app roles Step 6 'Assigning SynaMark app roles' if ($SkipAppRoles) { Write-Host ' Skipped (-SkipAppRoles). Assign roles in Entra ID > Enterprise applications > SynaMark > Users and groups.' } else { $sp = (Invoke-MgGraphRequest GET "/v1.0/servicePrincipals?`$filter=appId eq '$SynaMarkAppId'&`$select=id,appRoles").value | Select-Object -First 1 if (-not $sp) { Write-Warning "SynaMark is not yet consented in this tenant. Grant consent first: https://login.microsoftonline.com/$tenantId/adminconsent?client_id=$SynaMarkAppId - then re-run this script." } else { foreach ($pair in @(@{ Group = $admins; Role = 'SynaMark.Admin' }, @{ Group = $editors; Role = 'SynaMark.Editor' })) { $role = $sp.appRoles | Where-Object { $_.value -eq $pair.Role } $assigned = (Invoke-MgGraphRequest GET "/v1.0/servicePrincipals/$($sp.id)/appRoleAssignedTo").value | Where-Object { $_.principalId -eq $pair.Group.Id -and $_.appRoleId -eq $role.id } if (-not $assigned) { $body = @{ principalId = $pair.Group.Id; resourceId = $sp.id; appRoleId = $role.id } | ConvertTo-Json Invoke-MgGraphRequest POST "/v1.0/servicePrincipals/$($sp.id)/appRoleAssignedTo" -Body $body -ContentType 'application/json' | Out-Null } Write-Host " $($pair.Group.Name) -> $($pair.Role)" } } } Write-Host "`nSynaMark is set up. Site: $siteUrl" -ForegroundColor Green Write-Host 'Next: deploy the SynaMark add-in from Microsoft 365 admin center > Integrated apps, then open the SynaMark portal to design your signatures.' Disconnect-MgGraph | Out-Null Disconnect-SPOService