Public/Connect-Raidiness.ps1

function Connect-Raidiness {
    <#
    .SYNOPSIS
        Signs in to Microsoft Graph with the read-only scopes Raidiness needs.

    .DESCRIPTION
        A thin wrapper around Connect-MgGraph. By default it requests only the
        broadly applicable baseline in collect.manifest.json. Extra delegated
        permissions are grouped by capability and are requested only when named
        with OptionalScopeGroup. The module never requests write access.

        Sign-in is interactive (delegated). An account with the Global Reader
        role sees the most; a regular admin account still produces a useful
        (partial) assessment — anything unreadable degrades to "not measured".

        By default, Raidiness uses interactive browser authentication. Device
        code and Windows Web Account Manager (WAM) authentication are available
        as explicit alternatives through AuthenticationMode.

    .PARAMETER TenantId
        The tenant to sign in to. Optional for a single-tenant account.

    .PARAMETER Scopes
        Override the manifest scope selection with an exact list. This cannot
        be combined with OptionalScopeGroup.

    .PARAMETER OptionalScopeGroup
        Add one or more capability-specific scope groups to the baseline. Valid
        group names are listed by the parameter completion and in the collection
        manifest. Use only groups needed by the checks being run.

    .PARAMETER AuthenticationMode
        Select the interactive authentication flow. Browser (the default) opens
        an interactive browser and opts out of WAM. DeviceCode displays a code
        to enter at https://microsoft.com/devicelogin. WAM uses the native
        Windows Web Account Manager behavior supplied by Microsoft Graph.

    .EXAMPLE
        Connect-Raidiness
        Invoke-Raidiness
        Export-RaidinessReport

    .EXAMPLE
        Connect-Raidiness -AuthenticationMode DeviceCode
    #>

    [CmdletBinding()]
    param(
        [string] $TenantId,
        [string[]] $Scopes,
        [ValidateSet('AgentIdentity', 'Copilot', 'Defender', 'eDiscovery', 'Intune', 'Purview', 'SharePoint', 'Teams')]
        [string[]] $OptionalScopeGroup,
        [ValidateSet('Browser', 'DeviceCode', 'WAM')]
        [string] $AuthenticationMode = 'Browser'
    )

    $loadedVersions = @(
        Get-Module Raidiness -All |
            ForEach-Object { $_.Version.ToString() } |
            Sort-Object -Unique
    )
    if ($loadedVersions.Count -gt 1) {
        Write-Warning "Multiple Raidiness module versions are loaded: $($loadedVersions -join ', '). Open a clean PowerShell session, or remove all loaded copies with 'Remove-Module Raidiness -Force' before importing the intended version."
    }

    $ErrorActionPreference = 'Stop'

    if ($Scopes -and $OptionalScopeGroup) {
        throw 'Scopes is an exact override and cannot be combined with OptionalScopeGroup.'
    }

    $manifestPath = Join-Path $PSScriptRoot '..' 'collect.manifest.json'
    $manifest = Get-Content -Path $manifestPath -Raw | ConvertFrom-Json
    if (-not $Scopes) {
        if (-not ($manifest.PSObject.Properties['scopes'] -and $manifest.scopes.PSObject.Properties['baseline'])) {
            throw "The collection manifest at $manifestPath has no 'scopes.baseline' section. Reinstall the module, or pass -Scopes to request an explicit list."
        }
        $Scopes = @($manifest.scopes.baseline)

        # An omitted [string[]] parameter is $null, and @($null) still holds one
        # element, so filter before iterating; otherwise the loop runs once with
        # an empty group name and strict mode fails on the property lookup.
        foreach ($group in @($OptionalScopeGroup | Where-Object { $_ })) {
            $Scopes += @($manifest.scopes.optional.$group.scopes)
        }
        $Scopes = @($Scopes | Sort-Object -Unique)
    }

    $writeScopes = @($Scopes | Where-Object { $_ -notmatch '\.Read(\.|$)' -and $_ -ne 'openid' -and $_ -ne 'profile' })
    if ($writeScopes.Count -gt 0) {
        throw "Refusing to request non-read scopes: $($writeScopes -join ', '). Raidiness is read-only."
    }

    Write-Host 'Raidiness — read-only sign-in to Microsoft Graph.' -ForegroundColor Cyan
    Write-Host "Requesting $($Scopes.Count) read-only scopes. Nothing in your tenant will be changed." -ForegroundColor Cyan

    $parameters = @{ Scopes = $Scopes; NoWelcome = $true }
    if ($TenantId) { $parameters.TenantId = $TenantId }

    if ($AuthenticationMode -eq 'DeviceCode') {
        $parameters.UseDeviceCode = $true
        $deviceLoginUrl = 'https://microsoft.com/devicelogin'

        Write-Host 'Opening the Microsoft sign-in page in your default browser. Enter the code shown below.' -ForegroundColor Cyan
        try {
            $null = Start-Process -FilePath $deviceLoginUrl
        }
        catch {
            Write-Warning "Could not open the default browser automatically. Open $deviceLoginUrl yourself, then enter the code shown below."
        }
    }

    if ($AuthenticationMode -eq 'Browser') {
        $previousUseWam = [Environment]::GetEnvironmentVariable('MSAL_USE_WAM', 'Process')
        try {
            # Graph Authentication reads this opt-out while it initializes the
            # interactive credential, so it must surround Connect-MgGraph.
            [Environment]::SetEnvironmentVariable('MSAL_USE_WAM', 'false', 'Process')
            Connect-MgGraph @parameters
        }
        finally {
            [Environment]::SetEnvironmentVariable('MSAL_USE_WAM', $previousUseWam, 'Process')
        }
    }
    else {
        Connect-MgGraph @parameters
    }

    $context = Get-MgContext
    $tenantName = $null
    $initialDomain = $null
    try {
        # The authentication context only contains the tenant GUID. Resolve the
        # organisation once so the confirmation identifies the tenant in the
        # same way an administrator sees it in Microsoft 365.
        $organization = Invoke-MgGraphRequest -Method GET -Uri '/v1.0/organization?$select=displayName,verifiedDomains' -OutputType PSObject
        $tenant = @($organization.value | Select-Object -First 1)[0]
        $tenantName = $tenant.displayName
        $initialDomain = @(
            $tenant.verifiedDomains |
                Where-Object { $_.isInitial -eq $true } |
                ForEach-Object { $_.name } |
                Where-Object { $_ } |
                Select-Object -First 1
        )[0]
    }
    catch {
        Write-Verbose "Could not read the tenant display name from /organization: $($_.Exception.Message)"
    }

    if ($tenantName) {
        $tenantDescription = if ($initialDomain) { "$tenantName ($initialDomain)" } else { $tenantName }
        Write-Host "Connected to tenant $tenantDescription as $($context.Account)." -ForegroundColor Green
    }
    else {
        # A custom scope override may omit Organization.Read.All. Keep sign-in
        # usable in that case, but make the less-friendly fallback explicit.
        Write-Host "Connected to tenant $($context.TenantId) as $($context.Account)." -ForegroundColor Green
    }
}