bacnet_server/mutation.rs
1//! Opt-in authorization for the server's ten covered confirmed mutation
2//! services and the Channel writes an inbound WriteGroup makes (#1319).
3//!
4//! This is local application policy, not authentication. The decision itself
5//! is [`ServerPolicy::authorize_mutation`](crate::ServerPolicy::authorize_mutation);
6//! this module holds its context, the [`MutationPolicy`] hardening switch and
7//! the decision counters. DCC/ReinitializeDevice, the other unconfirmed
8//! services, reads, and queries keep their own behavior. Direct handler calls
9//! and trusted local writes are not gated here.
10
11use std::sync::atomic::{AtomicU64, Ordering};
12
13use bacnet_encoding::npdu::NpduAddress;
14use bacnet_services::cov::{SubscribeCOVPropertyRequest, SubscribeCOVRequest};
15use bacnet_services::cov_multiple::SubscribeCOVPropertyMultipleRequest;
16use bacnet_services::file::AtomicWriteFileRequest;
17use bacnet_services::list_manipulation::ListElementRequest;
18use bacnet_services::object_mgmt::{CreateObjectRequest, DeleteObjectRequest};
19use bacnet_services::wpm::WritePropertyAttempt;
20use bacnet_services::write_property::WritePropertyRequest;
21use bacnet_transport::port::TransportProvenance;
22use bacnet_types::MacAddr;
23use bacnet_types::enums::{ConfirmedServiceChoice, UnconfirmedServiceChoice};
24use bacnet_types::primitives::ObjectIdentifier;
25
26/// Channel/relay scope derived from ingress provenance, mirroring RB-09
27/// `ControlTrust`. Scope only, never leaf identity: a verified SC ingress
28/// asserts the channel/relay validation, not that a claimed SNET/SADR leaf
29/// is the authenticated peer. Provenance alone never authorizes; only the
30/// policy does (no static allowlist).
31///
32/// Direct ingress exposes its verified leaf as
33/// [`MutationAuthorizationContext::principal`]; this enum remains scope-only. The SC VMAC is payload-claimed inside the TLS
34/// channel and is not bound to the operational certificate. An unknown origin (including a
35/// hub-mediated unknown leaf, which arrives [`TransportProvenance::unverified`])
36/// never satisfies a baseline-only allow rule; receive-permission (e.g. an
37/// accepted COV subscription or audit receipt) is never write-permission.
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub enum MutationTrust {
40 /// Unverified legacy origin, including hub-mediated unknown leaves.
41 Unverified,
42 /// Authenticated immediate SC-TLS channel; leaf identity is separate.
43 VerifiedChannel,
44 /// Independently validated SC-hub relayed origin (post source admission;
45 /// the hub peer is not the leaf).
46 VerifiedRelay,
47}
48
49impl MutationTrust {
50 /// Derive the channel/relay scope from one reassembled ingress snapshot.
51 pub fn from_provenance(provenance: &TransportProvenance) -> Self {
52 if provenance.is_direct_peer() || provenance.is_hub_channel() {
53 Self::VerifiedChannel
54 } else if provenance.is_relayed_origin() {
55 Self::VerifiedRelay
56 } else {
57 Self::Unverified
58 }
59 }
60}
61
62/// Local authorization mode for the services represented by [`MutationTarget`]:
63/// ten confirmed services and inbound WriteGroup.
64///
65/// SC mTLS channel/peer authentication is **not service authorization**. Identities
66/// include claimed link/routed addresses separately from the direct-SC
67/// [`principal`](MutationAuthorizationContext::principal) an installed
68/// [`ServerPolicy`](crate::ServerPolicy) sees.
69/// DCC, admission, decoding and WPM element validation retain their existing precedence.
70///
71/// An inbound WriteGroup is decided once per Channel write it would make
72/// ([`MutationTarget::WriteGroup`]): `DenyAll` denies each one, and a policy
73/// may allow some Channels and not others (#1319). A denied write is
74/// skipped silently, since nothing answers an unconfirmed request, and counted.
75#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
76pub enum MutationPolicy {
77 /// Preserve default-allow behavior: without a policy every decision is
78 /// allowed; an installed policy must allow each one.
79 #[default]
80 Permissive,
81 /// Deny every covered decision, even with an allow-all policy installed.
82 /// The policy is not asked. Denials use SERVICES / SERVICE_REQUEST_DENIED.
83 DenyAll,
84}
85
86/// Decoded mutation target and parameters presented to local policy.
87#[derive(Debug, Clone, PartialEq)]
88pub enum MutationTarget {
89 /// One object/property write, including index, raw value, and priority.
90 WriteProperty(WritePropertyRequest),
91 /// The current complete WPM element, not the whole request or a future suffix.
92 WritePropertyMultiple(WritePropertyAttempt),
93 /// Requested object type/identifier and initial property values.
94 CreateObject(CreateObjectRequest),
95 /// Object to delete.
96 DeleteObject(DeleteObjectRequest),
97 /// Object/property list and all elements to add.
98 AddListElement(ListElementRequest),
99 /// Object/property list and all elements to remove.
100 RemoveListElement(ListElementRequest),
101 /// File identifier, access method, write position, and payload.
102 AtomicWriteFile(AtomicWriteFileRequest),
103 /// Object subscription, renewal, or cancellation and claimed process ID.
104 SubscribeCov(SubscribeCOVRequest),
105 /// Property subscription, renewal, or cancellation and claimed process ID.
106 SubscribeCovProperty(SubscribeCOVPropertyRequest),
107 /// Entire decoded multi-property subscription request, authorized once.
108 SubscribeCovPropertyMultiple(SubscribeCOVPropertyMultipleRequest),
109 /// One Channel Present_Value write an inbound WriteGroup would make,
110 /// decided per Channel.
111 WriteGroup(WriteGroupTarget),
112}
113
114/// One Channel write an inbound WriteGroup would make: the Channel, and the
115/// change-list entry and priority that reach it (Clause 15.11).
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct WriteGroupTarget {
118 /// The Channel whose Present_Value would be written.
119 pub channel: ObjectIdentifier,
120 /// The request's control group, one the Channel's Control_Groups holds.
121 pub group_number: u32,
122 /// The change-list entry's channel number, the Channel's Channel_Number.
123 pub channel_number: u16,
124 /// The priority of the write: the entry's own, or else the request's.
125 pub priority: u8,
126 /// The entry's BACnetChannelValue, encoded as the request carried it.
127 pub value: Vec<u8>,
128 /// Whether the request asks for the Channel's execution delays to be
129 /// skipped.
130 pub inhibit_delay: bool,
131}
132
133/// The service a mutation decision is for.
134#[derive(Clone, Copy, Debug, PartialEq, Eq)]
135pub enum MutationService {
136 /// A confirmed service, which the server answers.
137 Confirmed(ConfirmedServiceChoice),
138 /// An unconfirmed service: nothing goes back, a denial included.
139 Unconfirmed(UnconfirmedServiceChoice),
140}
141
142impl From<ConfirmedServiceChoice> for MutationService {
143 fn from(service: ConfirmedServiceChoice) -> Self {
144 Self::Confirmed(service)
145 }
146}
147
148impl MutationTarget {
149 /// Stable service-kind label for redacted diagnostics (no parameters).
150 pub fn kind(&self) -> &'static str {
151 match self {
152 Self::WriteProperty(_) => "write-property",
153 Self::WritePropertyMultiple(_) => "write-property-multiple",
154 Self::CreateObject(_) => "create-object",
155 Self::DeleteObject(_) => "delete-object",
156 Self::AddListElement(_) => "add-list-element",
157 Self::RemoveListElement(_) => "remove-list-element",
158 Self::AtomicWriteFile(_) => "atomic-write-file",
159 Self::SubscribeCov(_) => "subscribe-cov",
160 Self::SubscribeCovProperty(_) => "subscribe-cov-property",
161 Self::SubscribeCovPropertyMultiple(_) => "subscribe-cov-property-multiple",
162 Self::WriteGroup(_) => "write-group",
163 }
164 }
165}
166
167/// Claimed source identity, verified ingress scope, and decoded target
168/// supplied to mutation policy.
169///
170/// Neither address nor a subscription's process ID authenticates an operator.
171/// `source_mac` identifies the immediate peer (often a router); `source_network`
172/// is the peer-claimed routed origin, not a verified identity.
173/// SC mTLS authenticates the channel/peer, not service authorization; neither
174/// address is a certificate principal. [`principal`](Self::principal) is, and
175/// only for a verified direct SC connection.
176///
177/// `provenance` is the reassembled ingress snapshot threaded from dispatch
178/// (segmentation mismatches already fail closed at reassembly, so every
179/// element of one request — including each WPM element — observes the same
180/// snapshot). `trust` is the channel/relay scope derived from it, never leaf
181/// identity. Debug is redacted by construction: address lengths, whether a
182/// principal is present and the target kind only, never MAC bytes, the
183/// fingerprint, property values, file payloads, or other decoded inputs.
184#[derive(Clone, PartialEq)]
185pub struct MutationAuthorizationContext {
186 /// Immediate data-link peer address.
187 pub source_mac: MacAddr,
188 /// Claimed originating NPDU address, when present.
189 pub source_network: Option<NpduAddress>,
190 /// Honest transport + origin provenance for this ingress snapshot.
191 pub provenance: TransportProvenance,
192 /// Channel/relay scope derived from `provenance`; never leaf identity.
193 pub trust: MutationTrust,
194 /// A confirmed request's invoke identifier; `None` for an unconfirmed
195 /// request, which carries none.
196 pub invoke_id: Option<u8>,
197 /// The service that asks for the mutation.
198 pub service_choice: MutationService,
199 /// Decoded target and parameters; current element for WPM, current
200 /// Channel for WriteGroup.
201 pub target: MutationTarget,
202}
203
204impl MutationAuthorizationContext {
205 /// The principal of the peer that originated the request: the verified
206 /// leaf certificate of a direct BACnet/SC connection, when the request
207 /// carries no routed source. `None` for other ingress, hub-relayed and
208 /// routed requests included (see [`crate::policy`]).
209 pub fn principal(&self) -> Option<bacnet_transport::port::ScPrincipal> {
210 crate::policy::originating_principal(&self.provenance, self.source_network.as_ref())
211 }
212
213 /// The principal of the direct BACnet/SC peer that handed this request
214 /// over, a router included when the request was routed; `None` off a
215 /// verified direct SC connection. Trusting it trusts everything that
216 /// peer forwards.
217 pub fn forwarding_peer_principal(&self) -> Option<bacnet_transport::port::ScPrincipal> {
218 self.provenance.principal()
219 }
220
221 crate::policy::certificate_accessors!();
222
223 /// The immediate direct-SC hop's verified leaf and connection incarnation:
224 /// for a routed request, the router that forwarded it. Absent off a
225 /// verified direct SC connection. Separate from claimed addresses.
226 pub fn direct_sc_identity(&self) -> Option<bacnet_transport::port::DirectScIdentity> {
227 self.provenance.direct_sc_identity()
228 }
229}
230
231impl std::fmt::Debug for MutationAuthorizationContext {
232 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233 f.debug_struct("MutationAuthorizationContext")
234 .field("source_mac_len", &self.source_mac.len())
235 .field(
236 "source_network",
237 &self
238 .source_network
239 .as_ref()
240 .map(|source| (source.network, source.mac_address.len())),
241 )
242 .field("provenance", &self.provenance)
243 .field("principal", &self.principal().is_some())
244 .field("trust", &self.trust)
245 .field("invoke_id", &self.invoke_id)
246 .field("service_choice", &self.service_choice)
247 .field("target_kind", &self.target.kind())
248 .finish_non_exhaustive()
249 }
250}
251
252/// The shared endpoint's gate for writes to its local Device, given the same
253/// redacted context [`ServerPolicy::authorize_mutation`](crate::ServerPolicy::authorize_mutation)
254/// sees, under the same calling rules. A full server uses its
255/// [`ServerPolicy`](crate::ServerPolicy) instead.
256pub type DeviceWriteAuthorizer =
257 std::sync::Arc<dyn Fn(&MutationAuthorizationContext) -> bool + Send + Sync>;
258
259/// Saturating lifetime decision totals for one covered service.
260/// These count authorization decisions, not successful mutations or response delivery.
261#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
262pub struct MutationServiceCounters {
263 /// Allowed, with no policy or an approving one, in permissive mode.
264 pub allow_total: u64,
265 /// All denials: policy refusal/panic or deny-all mode.
266 pub deny_total: u64,
267 /// Deny-all mode denials, a subset of `deny_total`; no policy was asked.
268 pub policy_deny_total: u64,
269}
270
271/// Fixed-shape local telemetry, not a durable audit log; no source history is retained.
272/// New servers start at zero. Each field is independently sampled and saturates at
273/// `u64::MAX`, so snapshots are not atomic aggregates. Counters never affect policy.
274/// WPM counts each element reaching its gate, not requests or an unvisited suffix,
275/// even one the policy was asked about ahead of the handler (#1321, #2032).
276/// An element whose object appeared between that ahead pass and the writes,
277/// so the policy never saw it, is denied and counted in `deny_total`.
278/// Pre-gate failures and duplicates do not count. In permissive mode without
279/// a policy, a decision allows without decoding, so a later handler failure still counts.
280/// An inbound WriteGroup counts once per Channel write it would make.
281#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
282pub struct MutationDecisionCounters {
283 /// WriteProperty decisions.
284 pub write_property: MutationServiceCounters,
285 /// WritePropertyMultiple element decisions.
286 pub write_property_multiple: MutationServiceCounters,
287 /// CreateObject decisions.
288 pub create_object: MutationServiceCounters,
289 /// DeleteObject decisions.
290 pub delete_object: MutationServiceCounters,
291 /// AddListElement decisions.
292 pub add_list_element: MutationServiceCounters,
293 /// RemoveListElement decisions.
294 pub remove_list_element: MutationServiceCounters,
295 /// AtomicWriteFile decisions.
296 pub atomic_write_file: MutationServiceCounters,
297 /// SubscribeCOV decisions, including cancellation and renewal.
298 pub subscribe_cov: MutationServiceCounters,
299 /// SubscribeCOVProperty decisions, including cancellation and renewal.
300 pub subscribe_cov_property: MutationServiceCounters,
301 /// SubscribeCOVPropertyMultiple whole-request decisions.
302 pub subscribe_cov_property_multiple: MutationServiceCounters,
303 /// Decisions on the Channel writes inbound WriteGroups would make.
304 pub write_group: MutationServiceCounters,
305}
306
307#[derive(Clone, Copy, PartialEq, Eq)]
308pub(crate) enum MutationDecision {
309 Allow,
310 Deny,
311 PolicyDeny,
312}
313
314#[derive(Default)]
315pub(crate) struct MutationDecisions([[AtomicU64; 3]; 11]);
316
317impl MutationDecisions {
318 pub(crate) fn snapshot(&self) -> MutationDecisionCounters {
319 let [
320 write_property,
321 write_property_multiple,
322 create_object,
323 delete_object,
324 add_list_element,
325 remove_list_element,
326 atomic_write_file,
327 subscribe_cov,
328 subscribe_cov_property,
329 subscribe_cov_property_multiple,
330 write_group,
331 ] = self.0.each_ref().map(|row| {
332 let [allow_total, deny_total, policy_deny_total] =
333 row.each_ref().map(|n| n.load(Ordering::Relaxed));
334 MutationServiceCounters {
335 allow_total,
336 deny_total,
337 policy_deny_total,
338 }
339 });
340 MutationDecisionCounters {
341 write_property,
342 write_property_multiple,
343 create_object,
344 delete_object,
345 add_list_element,
346 remove_list_element,
347 atomic_write_file,
348 subscribe_cov,
349 subscribe_cov_property,
350 subscribe_cov_property_multiple,
351 write_group,
352 }
353 }
354
355 pub(crate) fn record(&self, service: impl Into<MutationService>, decision: MutationDecision) {
356 let service = match service.into() {
357 MutationService::Confirmed(service) => service,
358 MutationService::Unconfirmed(UnconfirmedServiceChoice::WRITE_GROUP) => {
359 return self.increment(10, decision);
360 }
361 MutationService::Unconfirmed(_) => return,
362 };
363 let index = match service {
364 ConfirmedServiceChoice::WRITE_PROPERTY => 0,
365 ConfirmedServiceChoice::WRITE_PROPERTY_MULTIPLE => 1,
366 ConfirmedServiceChoice::CREATE_OBJECT => 2,
367 ConfirmedServiceChoice::DELETE_OBJECT => 3,
368 ConfirmedServiceChoice::ADD_LIST_ELEMENT => 4,
369 ConfirmedServiceChoice::REMOVE_LIST_ELEMENT => 5,
370 ConfirmedServiceChoice::ATOMIC_WRITE_FILE => 6,
371 ConfirmedServiceChoice::SUBSCRIBE_COV => 7,
372 ConfirmedServiceChoice::SUBSCRIBE_COV_PROPERTY => 8,
373 ConfirmedServiceChoice::SUBSCRIBE_COV_PROPERTY_MULTIPLE => 9,
374 _ => return,
375 };
376 self.increment(index, decision);
377 }
378
379 fn increment(&self, index: usize, decision: MutationDecision) {
380 let increment = |column: usize| {
381 let _ = self.0[index][column].try_update(Ordering::Relaxed, Ordering::Relaxed, |n| {
382 Some(n.saturating_add(1))
383 });
384 };
385 match decision {
386 MutationDecision::Allow => increment(0),
387 MutationDecision::Deny => increment(1),
388 MutationDecision::PolicyDeny => {
389 increment(1);
390 increment(2);
391 }
392 }
393 }
394}
395
396#[cfg(test)]
397#[path = "mutation_counter_tests.rs"]
398mod counter_tests;