// ============================================================================= // Ploy — Azure Monitor Logs Ingestion setup // ----------------------------------------------------------------------------- // Run this in YOUR Azure subscription. It provisions the resources Ploy needs // to send audit-log records into your Log Analytics workspace / Microsoft // Sentinel via the Logs Ingestion API: // // 1. A custom Log Analytics table (name ends in _CL) — where records land // 2. A Data Collection Endpoint (DCE) — the ingestion URL // 3. A Data Collection Rule (DCR) — schema + routing // 4. A role assignment (Monitoring Metrics Publisher, — lets Ploy push data // scoped to the DCR) // // Before deploying, complete Step 1 of the setup guide: create the Entra app // registration and its OIDC federated credential. That is a one-off `az` CLI // step — it cannot be expressed in Bicep, and no client secret is involved. It // produces the service principal object ID this template takes as a parameter. // // Deploy at resource-group scope: // // az deployment group create \ // --resource-group \ // --template-file ploy-sentinel.bicep \ // --parameters workspaceName= tableName=Ploy_CL dceName= \ // dcrName= servicePrincipalObjectId= // ============================================================================= // ----------------------------------------------------------------------------- // Parameters // ----------------------------------------------------------------------------- @description('Name of your EXISTING Log Analytics workspace. It is only referenced — never created or modified — and must live in the same resource group as this deployment.') param workspaceName string @description('Azure region for the DCE and DCR. Defaults to the resource group location; keep it in the same region as the workspace.') param location string = resourceGroup().location @description('Name for the custom table Ploy writes to. Must end in _CL (Azure requires this for custom log tables), e.g. Ploy_CL.') param tableName string @description('Name for the Data Collection Endpoint (DCE) — the resource whose logs-ingestion URL Ploy posts to.') param dceName string @description('Name for the Data Collection Rule (DCR). Ploy references its immutable ID (not its name) in the ingestion URL.') param dcrName string @description('OBJECT ID (not the app/client ID) of the service principal created in Step 1. Monitoring Metrics Publisher is granted to it, scoped to this DCR only. Get it with: az ad sp show --id --query id -o tsv') param servicePrincipalObjectId string @description('Optional tags applied to the DCE and DCR.') param tags object = {} // ----------------------------------------------------------------------------- // Variables // ----------------------------------------------------------------------------- // Single source of truth for the schema: the custom table and the DCR stream // both consume it, so they can never drift apart. These columns must match the // fields Ploy sends — a DCR silently DROPS any column it does not recognise (the // record is accepted with HTTP 204 and then discarded), so do not rename or // remove columns. TimeGenerated is required by Log Analytics; Targets and // Payload are `dynamic` because they hold nested JSON. var tableSchemaColumns = [ { name: 'TimeGenerated' type: 'datetime' } // Authoritative event time. Azure rewrites TimeGenerated to the ingestion time // for events older than ~2 days, so backfilled or delayed events land at "now" // on that axis. Query and correlate on OccurredAt, which is never rewritten. { name: 'OccurredAt' type: 'datetime' } { name: 'EventId' type: 'string' } { name: 'CategoryUid' type: 'int' } { name: 'ClassUid' type: 'int' } { name: 'ClassName' type: 'string' } { name: 'ActivityId' type: 'int' } { name: 'ActivityName' type: 'string' } { name: 'TypeUid' type: 'int' } { name: 'Action' type: 'string' } { name: 'ActorType' type: 'string' } { name: 'ActorId' type: 'string' } { name: 'ActorEmail' type: 'string' } { name: 'ActorName' type: 'string' } { name: 'Targets' type: 'dynamic' } { name: 'Result' type: 'string' } { name: 'IpAddress' type: 'string' } { name: 'UserAgent' type: 'string' } { name: 'Payload' type: 'dynamic' } ] // For a DCR writing into a custom table, both the incoming stream and the // outputStream must be exactly 'Custom-'. Derived rather than exposed // as a parameter so it cannot be set to a value that would silently break // ingestion. var streamName = 'Custom-${tableName}' // A handle for the workspace destination inside the DCR — unique within the // DCR's destinations list only, not a resource name. var workspaceDestinationName = 'customerWorkspace' // Built-in "Monitoring Metrics Publisher" role — the minimum needed to push // data through the Logs Ingestion API against a DCR. This GUID is the same in // every Azure tenant. var monitoringMetricsPublisherRoleId = '3913510d-42f4-4e42-8a64-420c390055eb' // ----------------------------------------------------------------------------- // Existing workspace — referenced, not managed. Nothing about it is altered // beyond adding the custom table below. // ----------------------------------------------------------------------------- resource workspace 'Microsoft.OperationalInsights/workspaces@2022-10-01' existing = { name: workspaceName } // ----------------------------------------------------------------------------- // 1. Custom table — where the ingested records land. A child of the workspace; // its schema comes from tableSchemaColumns. // ----------------------------------------------------------------------------- resource customTable 'Microsoft.OperationalInsights/workspaces/tables@2022-10-01' = { parent: workspace name: tableName properties: { schema: { name: tableName columns: tableSchemaColumns } } } // ----------------------------------------------------------------------------- // 2. Data Collection Endpoint (DCE) — the regional ingestion front door Ploy // posts to. Public network access is enabled so that Ploy can reach it. // ----------------------------------------------------------------------------- resource dce 'Microsoft.Insights/dataCollectionEndpoints@2023-03-11' = { name: dceName location: location tags: tags properties: { networkAcls: { publicNetworkAccess: 'Enabled' } } } // ----------------------------------------------------------------------------- // 3. Data Collection Rule (DCR) — schema in, custom table out. // - dataCollectionEndpointId binds the rule to the DCE above. // - streamDeclarations describes the shape Ploy POSTs. // - dataFlows routes the stream to the workspace and into the custom table. // ----------------------------------------------------------------------------- resource dcr 'Microsoft.Insights/dataCollectionRules@2023-03-11' = { name: dcrName location: location tags: tags properties: { dataCollectionEndpointId: dce.id streamDeclarations: { '${streamName}': { columns: tableSchemaColumns } } destinations: { logAnalytics: [ { workspaceResourceId: workspace.id name: workspaceDestinationName } ] } dataFlows: [ { streams: [ streamName ] destinations: [ workspaceDestinationName ] // 'source' passes every record through untouched. Replace with a KQL // expression to reshape, filter, or enrich before storage. transformKql: 'source' outputStream: streamName } ] } dependsOn: [ customTable ] } // ----------------------------------------------------------------------------- // 4. Role assignment — grant Ploy's service principal permission to push, // scoped to THIS DCR only (least privilege). The name is a deterministic // GUID, so re-running the deployment is idempotent. // ----------------------------------------------------------------------------- resource dcrRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { name: guid(dcr.id, servicePrincipalObjectId, monitoringMetricsPublisherRoleId) scope: dcr properties: { roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', monitoringMetricsPublisherRoleId) principalId: servicePrincipalObjectId // Declaring the principal type avoids a replication race where a freshly // created service principal is not yet visible to the role-assignment API. principalType: 'ServicePrincipal' } } // ----------------------------------------------------------------------------- // Outputs — hand these back to Ploy, alongside the tenant ID and client ID from // Step 1. // ----------------------------------------------------------------------------- @description('DCE logs-ingestion endpoint URL. Ploy POSTs to /dataCollectionRules//streams/.') output dceIngestionEndpoint string = dce.properties.logsIngestion.endpoint @description('DCR immutable ID — the opaque ID (e.g. dcr-abc123...) used in the ingestion URL, NOT the DCR resource name.') output dcrImmutableId string = dcr.properties.immutableId @description('The stream name Ploy targets in the ingestion URL (Custom-).') output streamName string = streamName @description('Full DCR resource ID — for reference or scripting; not required by Ploy.') output dcrResourceId string = dcr.id