Skip to content

Commit 6b0b625

Browse files
robgrameCopilot
andcommitted
Phase 3 infra: agent-seal KV key + sealed-batches SB queue
- Adds blkmon-agent-seal RSA 3072 key with encrypt/decrypt ops and the same rotation policy (P1Y rotate, P2Y expiry, P30D notify) as the KEK. - Role assignments scoped to the SPECIFIC key (not vault): Functions UAMI gets Crypto User for encrypt, optional hybridAgentPrincipalId param gets Crypto User for decrypt. Built-in RBAC cannot separate encrypt-only/decrypt-only at key level; operational boundary preserved by identity isolation (custom role pair available as future hardening). - Adds dek-sealed-batches SB queue with lockDuration=5m (covers KV decrypt + AES-GCM open + slow on-prem DB INSERT), TTL=2h, dedup=20m, deadLetter on expiry. - Queue-scoped Service Bus Data Receiver role for the agent identity (only dek-sealed-batches, not the namespace) - empty list disables until the agent identity is provisioned. - App Config seed keys: blkmon:Encryption:KeyVault:SealKeyName and blkmon:ServiceBus:Queues:SealedBatches. - New main.bicep param hybridAgentPrincipalId (defaults to empty; provision the agent identity then re-run azd to wire role assignments). - New outputs: AZURE_AGENT_SEAL_KEY_NAME, AZURE_AGENT_SEAL_KEY_URI, AZURE_SEALED_BATCHES_QUEUE. az bicep build validates clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b73283d commit 6b0b625

4 files changed

Lines changed: 147 additions & 0 deletions

File tree

infra/main.bicep

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ param sqlAdminPrincipalId string = ''
2121
@description('Display name for the SQL AAD admin (group display name or UPN).')
2222
param sqlAdminLoginName string = 'BitLockerMonitor-SqlAdmins'
2323

24+
@description('Object ID of the on-prem HybridAgent identity (SPN or UAMI). Granted decrypt on the agent-seal Key Vault key and Service Bus Data Receiver on the dek-sealed-batches queue. Leave empty before the agent identity exists; populate and re-run azd after provisioning.')
25+
param hybridAgentPrincipalId string = ''
26+
2427
@description('Tags applied to every resource.')
2528
param tags object = {
2629
'azd-env-name': environmentName
@@ -73,6 +76,12 @@ module keyvault 'modules/keyvault.bicep' = {
7376
identity.outputs.webIdentityPrincipalId
7477
identity.outputs.funcIdentityPrincipalId
7578
]
79+
sealKeyEncryptPrincipalIds: [
80+
identity.outputs.funcIdentityPrincipalId
81+
]
82+
sealKeyDecryptPrincipalIds: empty(hybridAgentPrincipalId) ? [] : [
83+
hybridAgentPrincipalId
84+
]
7685
enablePurgeProtection: !startsWith(environmentName, 'dev')
7786
}
7887
}
@@ -124,6 +133,9 @@ module serviceBus 'modules/servicebus.bicep' = {
124133
identity.outputs.funcIdentityPrincipalId
125134
identity.outputs.webIdentityPrincipalId
126135
]
136+
sealedBatchReceiverPrincipalIds: empty(hybridAgentPrincipalId) ? [] : [
137+
hybridAgentPrincipalId
138+
]
127139
}
128140
}
129141

@@ -215,3 +227,8 @@ output FUNC_IDENTITY_CLIENT_ID string = identity.outputs.funcIdentityClientId
215227
output AZURE_AUDIT_LA_DCE_ENDPOINT string = auditMirror.outputs.dceLogsIngestionEndpoint
216228
output AZURE_AUDIT_LA_DCR_IMMUTABLE_ID string = auditMirror.outputs.dcrImmutableId
217229
output AZURE_AUDIT_LA_STREAM_NAME string = auditMirror.outputs.streamName
230+
231+
// Phase 3 outputs
232+
output AZURE_AGENT_SEAL_KEY_NAME string = keyvault.outputs.agentSealKeyName
233+
output AZURE_AGENT_SEAL_KEY_URI string = keyvault.outputs.agentSealKeyUri
234+
output AZURE_SEALED_BATCHES_QUEUE string = serviceBus.outputs.sealedBatchesQueueName

infra/modules/appconfig.bicep

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,24 @@ resource sbAdEventsQueue 'Microsoft.AppConfiguration/configurationStores/keyValu
109109
}
110110
}
111111

112+
resource sbSealedBatchesQueue 'Microsoft.AppConfiguration/configurationStores/keyValues@2024-05-01' = {
113+
parent: appConfig
114+
name: 'blkmon:ServiceBus:Queues:SealedBatches$${environmentLabel}'
115+
properties: {
116+
value: 'dek-sealed-batches'
117+
contentType: 'text/plain'
118+
}
119+
}
120+
121+
resource sealKeyName 'Microsoft.AppConfiguration/configurationStores/keyValues@2024-05-01' = {
122+
parent: appConfig
123+
name: 'blkmon:Encryption:KeyVault:SealKeyName$${environmentLabel}'
124+
properties: {
125+
value: 'blkmon-agent-seal'
126+
contentType: 'text/plain'
127+
}
128+
}
129+
112130
resource kvUriKey 'Microsoft.AppConfiguration/configurationStores/keyValues@2024-05-01' = {
113131
parent: appConfig
114132
name: 'blkmon:KeyVault:Uri$${environmentLabel}'

infra/modules/keyvault.bicep

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ param tenantId string
66
param deployerPrincipalId string
77
@description('Object IDs of workload identities (web, func) that need wrapKey/unwrapKey for the KEK and Get for secrets resolved via App Configuration KV-references.')
88
param consumerPrincipalIds array
9+
@description('Object IDs of identities that need encrypt rights on the agent-seal key (Phase 3 Functions producer). Typically the Functions UAMI. Empty disables.')
10+
param sealKeyEncryptPrincipalIds array = []
11+
@description('Object IDs of identities that need decrypt rights on the agent-seal key (Phase 3 HybridAgent consumer). Typically the HybridAgent UAMI / SPN. Empty disables — provision the agent identity then re-run azd.')
12+
param sealKeyDecryptPrincipalIds array = []
913
@description('Enable purge protection. Once enabled, CANNOT be disabled. Keep true for prod, set false for ephemeral dev environments.')
1014
param enablePurgeProtection bool = true
1115

@@ -105,6 +109,77 @@ resource secretsUserRa 'Microsoft.Authorization/roleAssignments@2022-04-01' = [f
105109
}
106110
}]
107111

112+
// ---- Phase 3: agent-seal key (envelope sealing of per-batch ephemeral DEKs) ----
113+
// Separate key from the operational KEK so RBAC can be partitioned: only the Functions producer
114+
// gets encrypt and only the HybridAgent consumer gets decrypt. Both role assignments are scoped
115+
// to THIS key (not the vault) so a compromise of either workload cannot read/write the KEK.
116+
//
117+
// Note: Azure RBAC does not have separate built-in roles for encrypt-only vs decrypt-only at
118+
// the key level — both encrypt and decrypt come bundled with Key Vault Crypto User. Operationally
119+
// we never call decrypt from Functions or encrypt from the agent, so the security boundary
120+
// rests on the key-scoped assignment isolating these two identities from the operational KEK.
121+
// A future hardening pass could introduce a custom RBAC role pair for true encrypt/decrypt split.
122+
123+
resource agentSealKey 'Microsoft.KeyVault/vaults/keys@2024-11-01' = {
124+
parent: keyVault
125+
name: 'blkmon-agent-seal'
126+
properties: {
127+
kty: 'RSA'
128+
keySize: 3072
129+
keyOps: [
130+
'encrypt'
131+
'decrypt'
132+
]
133+
attributes: {
134+
enabled: true
135+
exportable: false
136+
}
137+
rotationPolicy: {
138+
lifetimeActions: [
139+
{
140+
trigger: {
141+
timeAfterCreate: 'P1Y'
142+
}
143+
action: {
144+
type: 'rotate'
145+
}
146+
}
147+
{
148+
trigger: {
149+
timeBeforeExpiry: 'P30D'
150+
}
151+
action: {
152+
type: 'notify'
153+
}
154+
}
155+
]
156+
attributes: {
157+
expiryTime: 'P2Y'
158+
}
159+
}
160+
}
161+
}
162+
163+
resource sealKeyEncryptRa 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for pid in sealKeyEncryptPrincipalIds: {
164+
scope: agentSealKey
165+
name: guid(agentSealKey.id, pid, kvCryptoUserRoleId, 'encrypt')
166+
properties: {
167+
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', kvCryptoUserRoleId)
168+
principalId: pid
169+
principalType: 'ServicePrincipal'
170+
}
171+
}]
172+
173+
resource sealKeyDecryptRa 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for pid in sealKeyDecryptPrincipalIds: {
174+
scope: agentSealKey
175+
name: guid(agentSealKey.id, pid, kvCryptoUserRoleId, 'decrypt')
176+
properties: {
177+
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', kvCryptoUserRoleId)
178+
principalId: pid
179+
principalType: 'ServicePrincipal'
180+
}
181+
}]
182+
108183
resource sqlConnSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = {
109184
parent: keyVault
110185
name: 'sql-connection-string'
@@ -120,4 +195,6 @@ output keyVaultName string = keyVault.name
120195
output keyVaultUri string = keyVault.properties.vaultUri
121196
output kekName string = kek.name
122197
output kekUri string = kek.properties.keyUriWithVersion
198+
output agentSealKeyName string = agentSealKey.name
199+
output agentSealKeyUri string = agentSealKey.properties.keyUriWithVersion
123200
output sqlConnectionStringSecretUri string = sqlConnSecret.properties.secretUriWithVersion

infra/modules/servicebus.bicep

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ param namespaceName string
55
param consumerPrincipalIds array
66
@description('Object IDs that need to send messages (Azure Service Bus Data Sender).')
77
param senderPrincipalIds array
8+
@description('Object IDs that need to RECEIVE specifically from the sealed-batches queue (Phase 3 HybridAgent). Optional — empty disables. Defaults to none because the agent identity must be provisioned out-of-band first.')
9+
param sealedBatchReceiverPrincipalIds array = []
810

911
var sbReceiverRoleId = '4f6d3b9b-027b-4f4c-9142-0e5a2a2247e0' // Azure Service Bus Data Receiver
1012
var sbSenderRoleId = '69a216fc-b8fb-44d8-bc22-1f3c2cd27a39' // Azure Service Bus Data Sender
@@ -49,6 +51,25 @@ resource adEventsQueue 'Microsoft.ServiceBus/namespaces/queues@2024-01-01' = {
4951
}
5052
}
5153

54+
// ---- Phase 3: sealed DEK batches Functions -> HybridAgent ----
55+
// Generous lockDuration (5m) accommodates KV decrypt + AES-GCM open + DB insert on a slow on-prem
56+
// connection. TTL=2h aligns with the in-message ExpiresAtUtc default (10m * 12 redelivery cycles).
57+
// Duplicate detection: the message body changes per batch (random nonces) but we leave dedup on as
58+
// a defence in depth against a buggy producer republishing the same Functions invocation.
59+
resource sealedBatchesQueue 'Microsoft.ServiceBus/namespaces/queues@2024-01-01' = {
60+
parent: sbNamespace
61+
name: 'dek-sealed-batches'
62+
properties: {
63+
maxDeliveryCount: 5
64+
lockDuration: 'PT5M'
65+
defaultMessageTimeToLive: 'PT2H'
66+
deadLetteringOnMessageExpiration: true
67+
requiresDuplicateDetection: true
68+
duplicateDetectionHistoryTimeWindow: 'PT20M'
69+
enableBatchedOperations: true
70+
}
71+
}
72+
5273
resource receiverRa 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for pid in consumerPrincipalIds: {
5374
scope: sbNamespace
5475
name: guid(sbNamespace.id, pid, sbReceiverRoleId)
@@ -69,7 +90,21 @@ resource senderRa 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for pi
6990
}
7091
}]
7192

93+
// Queue-scoped receiver role for the HybridAgent identity so the agent can ONLY consume
94+
// the sealed-batches queue, not other namespace queues. Empty array disables — provision the
95+
// agent identity and re-run azd with the parameter populated.
96+
resource sealedBatchAgentReceiverRa 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for pid in sealedBatchReceiverPrincipalIds: {
97+
scope: sealedBatchesQueue
98+
name: guid(sealedBatchesQueue.id, pid, sbReceiverRoleId)
99+
properties: {
100+
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', sbReceiverRoleId)
101+
principalId: pid
102+
principalType: 'ServicePrincipal'
103+
}
104+
}]
105+
72106
output fullyQualifiedNamespace string = '${sbNamespace.name}.servicebus.windows.net'
73107
output namespaceId string = sbNamespace.id
74108
output scanTriggersQueueName string = scanTriggersQueue.name
75109
output adEventsQueueName string = adEventsQueue.name
110+
output sealedBatchesQueueName string = sealedBatchesQueue.name

0 commit comments

Comments
 (0)