Skip to main content

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;