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;