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