Framework/Core/InClusterCA/ContinuousAssuranceForDatabricks.ps1

class DatabricksClusterCA : CommandBase {
    # workspace base URL is required to make all the
    # API calls
    [PSObject] $ResourceContext;
    [string] $PersonalAccessToken;
    [string] $AzSKSecretScopeName = "AzSK_CA_Secret_Scope";


    DatabricksClusterCA([PSObject] $ResourceContext, [InvocationInfo] $invocationContext): 
    Base([Constants]::BlankSubscriptionId, $invocationContext) { 
        $this.ResourceContext = $ResourceContext
    }

    static [PSObject] GetParameters($SubscriptionId, $WorkspaceName, $ResourceGroupName, $PAT) {
        if([string]::IsNullOrEmpty($SubscriptionId) -or 
            [string]::IsNullOrEmpty($WorkspaceName) -or
            [string]::IsNullOrEmpty($ResourceGroupName) -or
            [string]::IsNullOrEmpty($PAT)) {
            Write-Host "Input the following parameters"
            if ([string]::IsNullOrEmpty($SubscriptionId)) {
                $SubscriptionId = [Helpers]::ReadInput("Subscription ID")
            }
            if ([string]::IsNullOrEmpty($WorkspaceName)) {
                $WorkspaceName = [Helpers]::ReadInput("Databricks Workspace Name")
            }
            if ([string]::IsNullOrEmpty($ResourceGroupName)) {
                $ResourceGroupName = [Helpers]::ReadInput("Databricks Resource Group Name")
            }
            if ([string]::IsNullOrEmpty($PAT)) {
                $PAT = [Helpers]::ReadInput("Personal Access Token(PAT)")
            }
        }
        Set-AzContext -SubscriptionId $SubscriptionId *> $null
        $response = Get-AzResource -Name $WorkspaceName -ResourceGroupName $ResourceGroupName
        $response = $response | Where-Object{$_.ResourceType -eq "Microsoft.Databricks/workspaces"}
        # wrong name entered, no resource with that name found
        if ($null -eq $response) {
            Write-Host "Error: Resource [$WorkspaceName] not found in current subscription. Please recheck" -ForegroundColor Red
            return $null
        }
        $WorkspaceBaseUrl = "https://" +  $response.Location + ".azuredatabricks.net"
        return @{
            "SubscriptionId" = $SubscriptionId;
            "ResourceGroupName" = $ResourceGroupName;
            "WorkspaceName" = $WorkspaceName;
            "PersonalAccessToken" = $PAT;
            "WorkspaceBaseUrl" = $WorkspaceBaseUrl
        }
    }

    [void] InferLASettings() {
        # infer LA settings if not passed
        if ([string]::IsNullOrEmpty($this.ResourceContext.LAWorkspaceId)) {
            [LogAnalyticsHelper]::SetLAWSDetails()
            $settings = [ConfigurationManager]::GetAzSKSettings()
            $this.ResourceContext.LAWorkspaceId = $settings.LAWSId
            $this.ResourceContext.LASharedSecret = $settings.LAWSSharedKey
        } 
    }

    [PSObject] InvokeRestAPICall($EndPoint, $Method, $Body, $ErrorMessage) {
        try {
            $Header = @{
                "Authorization" = "Bearer " + $this.ResourceContext.PersonalAccessToken;
            }
            $URI = $this.ResourceContext.WorkspaceBaseURL + $EndPoint
            if ($Method -eq "GET") {
                $response = Invoke-RestMethod -Method $Method -Uri $uri `
                    -Headers $Header `
                    -ContentType 'application/json' -UseBasicParsing
            } else {
                $response = Invoke-RestMethod -Method $Method -Uri $uri `
                    -Headers $Header `
                    -Body $Body `
                    -ContentType 'application/json' -UseBasicParsing
            }
            return $response
        } catch {
            Write-Host $_
            $this.PublishCustomMessage($ErrorMessage, [MessageType]::Error)
            throw $_
        }
    }

    [void] InsertDataIntoDB($SecretKeyName, $Secret) {
        if ([string]::IsNullOrEmpty($Secret)) {
            $this.PublishCustomMessage("Skipping inserting $SecretKeyName into cluster")
            return
        }
        $params = @{
            "scope"        = $this.AzSKSecretScopeName;
            "key"          = $SecretKeyName;
            "string_value" = $Secret
        }
        $bodyJson = $params | ConvertTo-Json
        $endPoint = "/api/2.0/secrets/put"
        $ResponseObject = $this.InvokeRestAPICall($endPoint, "POST", $bodyJson, "Unable to create/update secret value, remaining steps will be skipped.")
    }

    [bool] CheckSecretPresence($SecretName, $SecretKey, $response) {
        if ($response.secrets.key.Contains($SecretKey)) {
            return $true
        } else {
            $this.PublishCustomMessage("$SecretName absent in the cluster", [MessageType]::Error)
            return $false
        }
    }

    [void] GetCA() {
        # check secret scope exist
        if(-not $this.CheckAzSKSecretScopeExists()){
            $this.PublishCustomMessage("AzSK scope not found. CA isn't functioning properly", [MessageType]::Error)
            return
        } 
        $ListEP = "/api/2.0/secrets/list?scope=AzSK_CA_Secret_Scope"
        $fail = $false
        $response = $this.InvokeRestAPICall($ListEP, "GET", $null, "Unable to fetch secrets. Please check if CA instance is present")
        $this.PublishCustomMessage("Checking if runtime permissions are present")
        $res = $this.CheckSecretPresence("AzSK Scan Key", "AzSK_CA_Scan_Key", $response)
        $res = $res -and ($this.CheckSecretPresence("Databricks Host Name", "DatabricksHostDomain" , $response))
        $res = $res -and ($this.CheckSecretPresence("Resource Name", "res_name" , $response))
        $res = $res -and ($this.CheckSecretPresence("Resource Group Name", "rg_name" , $response))
        $res = $res -and ($this.CheckSecretPresence("Subscription ID", "sid" , $response))
        $foo = $this.CheckSecretPresence("Application Insight Key", "AzSK_AppInsight_Key", $response)
        $CAScanJob = $this.CheckAzSKJobExists()
        if (-not $CAScanJob) {
            $this.PublishCustomMessage("CA Scan Job is absent", [MessageType]::Error)
            $fail = $true
        }
        if ($res -and -not $fail) {
            $this.PublishCustomMessage("All required permissions and files present. CA Health OK")
        } else {
            $this.PublishCustomMessage("Not all required permissions and files are present. CA might not function properly", [MessageType]::Error)
        }
        
    }

    [bool] CheckAzSKSecretScopeExists() {
        $endPoint = "/api/2.0/secrets/scopes/list"
        $SecretScopeAlreadyExists = $false
        $Body = @{
            'scope' = $this.AzSKSecretScopeName;
        } | ConvertTo-Json
        $SecretScopes = $this.InvokeRestAPICall($endPoint, "GET", $Body, "Unable to fetch secret scope, remaining steps will be skipped.")
        if (-not [string]::IsNullOrEmpty($SecretScopes) `
                -and ("scopes" -in $SecretScopes.PSobject.Properties.Name) `
                -and ($SecretScopes.scopes | Measure-object).Count -gt 0) {
            $SecretScope = $SecretScopes.scopes | Where { $_.name -eq $this.AzSKSecretScopeName }
            if ($SecretScope -ne $null -and ( $SecretScope | Measure-Object).count -gt 0) {
                $SecretScopeAlreadyExists = $true
            }
        }
        return $SecretScopeAlreadyExists
    }

    [string] GetAzSKNotebookContent() {
        $NotebookUrl = [Constants]::DatabricksCANotebookUrl
        # Download notebook flrom server and store it in temp location
        $filePath = $env:TEMP + "\AzSK_CA_Scan_Notebook.ipynb"
        Invoke-RestMethod  -Method Get -Uri $NotebookUrl -OutFile $filePath
        $fileContent = get-content $filePath
        $fileContentBytes = [System.Text.Encoding]::UTF8.GetBytes($fileContent)
        $fileContentEncoded = [System.Convert]::ToBase64String($fileContentBytes)
        # cleanup notebook from temp location
        Remove-Item $filePath -ErrorAction Ignore
        return $fileContentEncoded;
    }

    [bool] CheckAzSKJobExists() {
        $JobAlreadyExists = $false
        $EndPoint = "/api/2.0/jobs/list"
        $JobList = $this.InvokeRestAPICall($EndPoint, "GET", $null, 
            "Unable to list jobs in workspace, remaining steps will be skipped.")
        if (-not [string]::IsNullOrEmpty($JobList) `
                -and ("jobs" -in $JobList.PSobject.Properties.Name) `
                -and ($JobList.jobs | Measure-object).Count -gt 0) {
            $AzSKJobs = $JobList.jobs | where { $_.settings.name -eq 'AzSK_CA_Scan_Job' }
            if ($AzSKJobs -ne $null -and ( $AzSKJobs | Measure-Object).count -gt 0) {
                $JobAlreadyExists = $true
            }
        }
        return $JobAlreadyExists;
    }

    [void] RemoveAzSKScanJob() {
        $EndPoint = "/api/2.0/jobs/list"
        $JobList = $this.InvokeRestAPICall($EndPoint, "GET", $null, 
            "Unable to list jobs in workspace, remaining steps will be skipped.")
        # we know for sure that the job will exist
        $AzSKJobs = $JobList.jobs | where { $_.settings.name -eq 'AzSK_CA_Scan_Job' }
        $DeleteEndPoint = "/api/2.0/jobs/delete"
        ForEach ($job in $AzSKJobs) {
            $this.PublishCustomMessage("Deleting AzSKJob with ID: $($job.job_id) created by user $($job.creator_user_name)")
            $jid = @{"job_id" = "$($job.job_id)"} | ConvertTo-Json
            $this.InvokeRestAPICall($DeleteEndPoint, "POST", $jid, "Unable to delete job") 
        }
    }

    [bool] CheckAzSKWorkspaceExist() {
        $workspace = "/api/2.0/workspace/list?path=/"
        $response = $this.InvokeRestAPICall($workspace, "GET", $null, "Unable to fetch workspace.")
        $azskPath = $response | Where {$_.objects.path -eq "/AzSK"}
        if ([string]::IsNullOrEmpty($azskPath)) {
            return $false
        } else {
            return $true
        }
    }

    [void] CreateAzSKWorkspace() {
        $EndPoint = "/api/2.0/workspace/mkdirs"        
        $BodyJson = @{
            "path" = "/AzSK"
        } | ConvertTo-Json
        $ResponseObject = $this.InvokeRestAPICall($endPoint, "POST", $BodyJson, 
            "Unable to create folder in workspace, remaining steps will be skipped.")
    }

    [void] RemoveAzSKWorkspace() {
        $removeBody = @{
            "path" = "/AzSK";
            "recursive" = "true";
        }
        $removeBody = $removeBody | ConvertTo-Json
        $removeEP = "/api/2.0/workspace/delete"
        $this.InvokeRestAPICall($removeEP, "POST", $removeBody, "Unable to delete workspace")
    }

    [void] UploadAzSKNotebookToCluster() {
        # Create AzSK folder in user workspace, if folder already exists it will do nothing
        if (-not $this.CheckAzSKWorkspaceExist()) {
            $this.CreateAzSKWorkspace()
        }
        # Import notebook in user workspace,
        $BodyJson = @{
            'path'      = '/AzSK/AzSK_CA_Scan_Notebook';
            'format'    = 'JUPYTER';
            'language'  = 'PYTHON';
            'content'   = $this.GetAzSKNotebookContent();
            'overwrite' = 'true'
        } | ConvertTo-Json
        $endPoint = "/api/2.0/workspace/import"
        $this.PublishCustomMessage("Uploading AzSK Scan Notebook into the cluster workspace")
        $ResponseObject = $this.InvokeRestAPICall($endPoint, "POST", $BodyJson, "Unable to import notebook in workspace, remaining steps will be skipped.")
    }

    [void] CreateAzSKScanJob($Frequency) {
        $JobConfigServerUrl = [Constants]::DatabricksScanJobConfigurationUrl
        # if frequency is not mentioned, create the job with 24 hr interval at a
        # time one hour after the scan job is created
        if ([string]::IsNullOrEmpty($Frequency)) {
            $jobHrs = ((Get-Date).ToUniversalTime().Hour + 1) % 24
            $Schedule = "0 0 $jobHrs * * ?"
        } else {
            # if frequency is mentioned then run the
            # scan job once every $Frequency hours
            $Schedule = "0 0 */$Frequency * * ?"           
        }
        # schedule expects a single quote around it
        $Schedule = '"' + $Schedule + '"'
        # Create job
        $this.PublishCustomMessage("Creating Job 'AzSK_CA_Scan_Job' in the workspace")
        $filePath = $env:TEMP + "\DatabricksCAScanJobConfig.json"
        $configuration = Invoke-RestMethod -Uri $JobConfigServerUrl -Method "GET"
        $configuration = $configuration -Replace  '#Schedule#', $Schedule
        $EndPoint = "/api/2.0/jobs/create"
        $ResponseObject = $this.InvokeRestAPICall($EndPoint, "POST", $configuration, "Unable to create AzSK_CA_Scan_Job in workspace.")
        $this.PublishCustomMessage("Successfully created job 'AzSK_CA_Scan_Job' with Job ID: $($ResponseObject.job_id).")
    }

    [void] InstallCA() {
        # These are the keys that are stored in the secret scope
        $PatSecretKey = "AzSK_CA_Scan_Key"
        $WorkspaceNameKey = "res_name"
        $ResourceGroupNameKey = "rg_name"
        $IKKey = "AzSK_AppInsight_Key"
        $SubscriptionIdKey = "sid"
        $LASharedSecretKey = "LASharedSecret"
        $LAWorkspaceIdKey = "LAWorkspaceId"
        $HostNameKey = "DatabricksHostDomain"
        $NotebookFolderPath = "/AzSK"  

        # set log analytics keys
        $this.InferLASettings()

        # region Step 1: Create Secret Scope
        # Check if Secret Scope already exists
        if ($this.CheckAzSKSecretScopeExists()) {
            $this.PublishCustomMessage("Secret scope for AzSK already exists. We'll reuse it.",
                [MessageType]::Warning)
        } else {
            $Body = @{
                'scope' = $this.AzSKSecretScopeName;
            } | ConvertTo-Json
            $this.PublishCustomMessage("Creating a new secret scope [$($this.AzSKSecretScopeName)] in the workspace")
            $EndPoint = "/api/2.0/secrets/scopes/create"
            $ResponseObject = $this.InvokeRestAPICall($EndPoint, "POST", $Body, 
                "Unable to create secret scope, remaining steps will be skipped.")
        }

        
        # region Step 2: PUT Token in Secret Scope
        # If Secret already exists it will update secret value
        $this.PublishCustomMessage("Installing required secrets into the cluster")
        $this.InsertDataIntoDB($PatSecretKey,         $this.ResourceContext.PersonalAccessToken)
        $this.InsertDataIntoDB($WorkspaceNameKey,     $this.ResourceContext.WorkspaceName)
        $this.InsertDataIntoDB($ResourceGroupNameKey, $this.ResourceContext.ResourceGroupName)
        $this.InsertDataIntoDB($SubscriptionIdKey,    $this.ResourceContext.SubscriptionId)
        $this.InsertDataIntoDB($HostNameKey,          $this.ResourceContext.WorkspaceBaseUrl)
        $this.InsertDataIntoDB($LASharedSecretKey,    $this.ResourceContext.LASharedSecret)
        $this.InsertDataIntoDB($LAWorkspaceIdKey,     $this.ResourceContext.LAWorkspaceId)

        if ([string]::IsNullOrEmpty($this.ResourceContext.InstrumentationKey)) {
            $this.PublishCustomMessage("Skipping AppInsight installation, no Instrumentation Key passed")
        } else {
            $this.InsertDataIntoDB($this.AzSKSecretScopeName, $IKKey, $this.ResourceContext.InstrumentationKey)
        }
        

        # region Step 3: Set up AzSk Notebook in Databricks workspace
        # Create AzSK folder in user workspace, if folder already exists it will do nothing
        $this.UploadAzSKNotebookToCluster()

        # region Step 4: Schedule Notebook to run periodically,
        # Check if Job already exists
        if ($this.CheckAzSKJobExists()) {
            $this.PublishCustomMessage("AzSK job already exists. We'll reuse it.", [MessageType]::Warning)
        } else {
            # passing null will create a job with 24hr frequency
            $this.CreateAzSKScanJob($null)
        }

        # print log analytics settings
        $this.PublishCustomMessage("Log Analytics metrics will be sent to:")
        $this.PublishCustomMessage("Workspace Id: $($this.ResourceContext.LAWorkspaceId)")

        # Footer message
        $this.PublishCustomMessage("`n")
        $this.PublishCustomMessage([Constants]::DoubleDashLine)
        $this.PublishCustomMessage("** Next Steps **")
        $this.PublishCustomMessage("AzSK Continuous Assurance For Clusters Setup is completed.")
        $this.PublishCustomMessage("Your cluster will be scanned periodically by the AzSK CA.")
        $this.PublishCustomMessage("The first CA scan job will be triggered within the next 60 mins. You can check control evaluation results (job logs) after that.")
        $this.PublishCustomMessage("All security control evaluation results will also be sent to App Insight if an instrumentation key was provided during setup above.")
        $this.PublishCustomMessage("For more info, please see docs: https://aka.ms/devopskit/inclusterca")
    }

    [void] UpdateCA($NewPersonalAccessToken, $NewAppInsightKey, $NewSchedule) {
        $PatSecretKey = "AzSK_CA_Scan_Key"
        $IKKey = "AzSK_AppInsight_Key"

        # validation- secret scope should already exist
        if (-not $this.CheckAzSKSecretScopeExists()) {
            $this.PublishCustomMessage("AzSK secret scope not found. Please ensure the CA is installed.",
                                       [MessageType]::Error)
            return
        }
        # update secret scope values
        $this.InsertDataIntoDB($PatSecretKey, $NewPersonalAccessToken)
        $this.InsertDataIntoDB($IKKey,        $NewAppInsightKey)
        # update notebook content
        $this.UploadAzSKNotebookToCluster()
        # update schedule, if passed
        # validation- CA scan job should already exist
        if (-not [string]::IsNullOrEmpty($NewSchedule)) {
            if (-not $this.CheckAzSKJobExists()) {
                $this.PublishCustomMessage("CA scan job is abset. Please ensure the CA is installed.", 
                                           [MessageType]::Error)
                return
            } else {
                $this.RemoveAzSKScanJob()
            }
            $this.CreateAzSKScanJob($NewSchedule)
        }
    }

    [void] RemoveAzSKSecretScope() {
        $deleteEP = "/api/2.0/secrets/scopes/delete"
        $deleteBody = @{
            "scope" = "AzSK_CA_Secret_Scope";
        } | ConvertTo-Json
        $response = $this.InvokeRestAPICall($deleteEP, "POST", $deleteBody, "Unable to delete secret scope. Please retry")
        $this.PublishCustomMessage("Deleted AzSK Secret Scope")
    }

    [void] RemoveCA() {
        # remove workspace
        if ($this.CheckAzSKWorkspaceExist()) {
            $this.RemoveAzSKWorkspace()
            $this.PublishCustomMessage("AzSK Workspace removed.")
        } else {
            $this.PublishCustomMessage("AzSK workspace not found. Please ensure the CA is installed.",
                                       [MessageType]::Error)
            return
        }
        # remove secret scope
        if ($this.CheckAzSKSecretScopeExists()) {
            $this.RemoveAzSKSecretScope()
            $this.PublishCustomMessage("AzSK secret scope removed")
        } else {
            $this.PublishCustomMessage("AzSK secret scope doesn't exist. Please ensure the CA is installed.",
                                       [MessageType]::Error)
            return
        }
        # remove scan job
        if ($this.CheckAzSKJobExists()) {
            $this.RemoveAzSKScanJob()
            $this.PublishCustomMessage("AzSK scan job deleted.")
        } else {
            $this.PublishCustomMessage("AzSK scan job not found. Please ensure the CA is installed.",
                                       [MessageType]::Error)
        }
    }
}