Restrict who can change a device
Build and integrate / Restrict changes
A BACnetServer answers every peer that reaches it. Unless you configure it otherwise, any peer on any data link may write properties, create and delete objects, and subscribe. BACnet/SC mutual TLS doesn’t change that: it authenticates a connection, not a service. This page shows where to decide who may change your device, which identity each decision can rely on, and what Python exposes.
The page describes v0.15.0. Its example program allows writes and DCC only from one certificate name.
One policy makes the server’s decisions
Section titled “One policy makes the server’s decisions”Install one ServerPolicy with policy(..) on a server builder, or in ServerConfig::policy. Each method answers one question and has a default, so a policy overrides only what it decides. A covered mutation is any request in the table’s first two rows: one that changes the object database or adds a subscription.
| Request | Method | Without a policy | When the policy denies |
|---|---|---|---|
| WriteProperty, WritePropertyMultiple, CreateObject, DeleteObject, AddListElement, RemoveListElement, AtomicWriteFile and the three SubscribeCOV services | authorize_mutation |
Allowed | SERVICES / SERVICE_REQUEST_DENIED; nothing changes and no Audit record is written |
| WriteGroup, once per Channel it would write | authorize_mutation |
Allowed | That Channel is skipped silently (the service is unconfirmed) |
| DeviceCommunicationControl that passed the static checks | authorize_device_communication_control |
Allowed | SERVICES / SERVICE_REQUEST_DENIED; the state and any running timer stay as they were |
| LifeSafetyOperation | authorize_life_safety_operation |
Denied | SERVICES / SERVICE_REQUEST_DENIED |
| ConfirmedAuditNotification into the configured Audit Log sink | authorize_audit_notification |
Denied | SERVICES / SERVICE_REQUEST_DENIED; nothing is stored |
| UnconfirmedAuditNotification | authorize_unconfirmed_audit_notification |
Denied | Dropped silently |
Returning false denies, and so does a panic: the server catches it and treats it as a denial. Each method’s rustdoc says exactly when it is asked.
Every decision for a received request runs on Tokio’s blocking pool, with no database guard held, so a policy that blocks delays only its own request, and stop() doesn’t wait for it. A call that never returns still keeps the process from exiting. Keep each method fast, free of side effects and from calling back into the server; it may be called concurrently. WritePropertyMultiple asks about each element, in order up to the first denial, before it takes the write lock; elements before a denied one stay written.
Some decisions are settings rather than policy methods:
MutationPolicy::DenyAllrefuses every covered mutation without asking the policy.- DeviceCommunicationControl has a password, a static
DccPolicythat denies every request by default, a restriction to listed source addresses and a disable rate limit. These static checks, with the refusal of the deprecated DISABLE, run before the policy: a request any of them refuses never reaches it, so under the defaultDccPolicy::DenyAllthe policy is never asked. The policy is asked before the disable rate limit is charged, so a denied requester spends none of that shared budget.dcc_outcome_counterscounts a policy denial aspolicy_denied_total, along with the static refusals byDccPolicy::DenyAll, the source restriction and the rate limit. - ReinitializeDevice runs only when you install an
on_reinitializehandler, optionally behind a password. The policy doesn’t cover it; the handler’sReinitializeContexthas the sameprincipal()for its own checks. - TimeSynchronization is limited by
TimeSyncPolicy. - Routing control belongs to the router’s
ControlAuthorizer, not to the server. - A shared endpoint’s responder has its own mandatory Device-write authorizer.
Compose decisions with FnPolicy
Section titled “Compose decisions with FnPolicy”FnPolicy builds a policy from closures, one per decision: FnPolicy::new().mutation(..).device_communication_control(..).life_safety_operation(..). A decision without a closure keeps the default in the table. Without a mutation closure, the policy doesn’t decide mutations at all, and they are allowed as if no policy were installed. Calling policy(..) again replaces the earlier policy, so put every decision in one chain. A policy in an Arc or a Box is still a policy, so one policy can be shared.
Know who sent the request
Section titled “Know who sent the request”Every policy context carries the source addresses the request claims, its TransportProvenance (how it arrived: unverified, over a hub, or over a verified direct BACnet/SC connection), and two principals derived from them:
principal()is the fingerprint (ScPrincipal) of the verified leaf certificate, when the request came over a verified direct BACnet/SC connection from the device that originated it, andNoneotherwise.forwarding_peer_principal()is the fingerprint of the direct BACnet/SC peer that handed the request over, a router included.
Two kinds of request have no principal():
- Hub-relayed requests. The hub authenticated its own TLS client. The originating node’s certificate never reaches your device, so a policy can’t tell one hub member from another.
- Routed requests, even over a direct connection. A request with a routed source (SNET/SADR: the network number and address of a device on another network) came from a device behind a router, and the connection authenticates only the router.
forwarding_peer_principal()returns the router’s principal, for a policy that trusts that router with everything it forwards.
B/IP, B/IPv6, MS/TP and Ethernet requests carry no principal at all. A source MAC, VMAC, device UUID or SNET/SADR is a claim the peer makes, never an identity. A gateway that re-originates requests shows its own principal, so trust a principal as far as everything its holder may relay.
A principal fingerprints the exact leaf certificate, so renewing a certificate changes it. A rule on principals lists the fingerprints it trusts, and the list must change when a certificate is renewed. A rule on certificate names doesn’t.
Rules that survive certificate renewal
Section titled “Rules that survive certificate renewal”With the sc-tls feature, every policy context also has certificate() and forwarding_peer_certificate(): the verified leaf certificates behind the two principals, under the same rules. Both are None for hub-relayed and non-SC requests, and certificate() is None for a routed one. ScPeerCertificate::metadata() gives the subject, the issuer and subject_alt_names(), the certificate’s DNS, URI, email and IP names (ScSubjectAltName) in certificate order. A rule on a name keeps working when the certificate is renewed with the same name, with no fingerprint list to update.
Compare names for equality:
- A name is the certificate’s exact text. DNS case is not folded, and a wildcard such as
*.example.comis returned as written, never expanded. - An IP name compares as an IP address, so an IPv4-mapped IPv6 name doesn’t equal the IPv4 address.
- Don’t match by suffix. A rule that accepts any name ending in
.example.comaccepts every certificate the CA issues under that domain. - Allow on a name you find rather than deny on one you miss. A missing, repeated or malformed name extension gives no names, and
metadata()isNonewhen the certificate can’t be parsed. - Never log a certificate’s contents. The
Debugoutput of these types leaves them out.
A name is only as trustworthy as the CA that signed it: trust only the CAs that issue your operators’ certificates.
Limit what reaches the server on BACnet/SC
Section titled “Limit what reaches the server on BACnet/SC”These settings act before a request reaches the policy:
- Hub admission. A hub can bind each verified leaf certificate to its device UUID and allowed VMACs, and its admission policy can refuse same-UUID replacements. Admission doesn’t carry a principal through the hub. See hub certificate bindings and the BACnet/SC hub, which also covers
ScHubBroadcastRatePolicy. - Network-layer control messages. Each SC node limits the network-layer messages one source may hand in (
ScControlRateLimit); application requests are never charged. See who may change routes for the defaults and how sources are counted.
What Python exposes
Section titled “What Python exposes”Python’s BACnetServer takes:
mutation_policy="permissive", the default, or"deny_all", which denies the covered mutations through the same gate as Rust’sMutationPolicy::DenyAll. It also takes a callable that gets a redactedMutationContextand decides each covered mutation; an exception or an answer that isn’t a bool denies.dcc_policy,dcc_passwordanddcc_source_restrictionfor DeviceCommunicationControl, andtime_sync_policyfor clock changes.sc_control_rate_limit=(burst, per_second)for the BACnet/SC control-message rate limit, whichBACnetClienttakes too.
Python has no direct BACnet/SC listener, so a MutationContext’s principal and certificate accessors are always None. ScCertificateMetadata.subject_alt_names gives a certificate’s DNS, URI, email and IP names as written. BipEndpoint, ScEndpoint and MstpEndpoint take device_write_policy, a callable that decides writes to the local Device under the same fail-closed rules.
Record local access decisions
Section titled “Record local access decisions”BACnetServer::authorize_access_local records the result of an access decision your application made for a credential presented at an Access Point. It is physical access control, separate from the network decisions above; see completed local access authorization.
Read the contracts
Section titled “Read the contracts”- Server authorization policy: the mutation modes, principals, the authorization context, timing and decision counters.
- DeviceCommunicationControl local authorization:
DccPolicy, the source restriction, the rate limit and their order. - Time synchronization policy.
- bacnet_server::policy and TransportProvenance on docs.rs, for v0.15.0.
Example: writes and DCC from one certificate name
Section titled “Example: writes and DCC from one certificate name”This program allows covered mutations and DCC only from a verified direct BACnet/SC peer whose certificate carries a DNS name equal to the one you configure. It builds a server configuration with that policy, a DCC password and DccPolicy::RequirePassword, then checks that a request without a certificate is denied. It doesn’t open a socket.
Only a server with a direct BACnet/SC listener sees certificates. On a server that only joins a hub, every request is hub-relayed, and this policy denies every write and DCC request.
Save the program and manifest
Section titled “Save the program and manifest”Put Cargo.toml in a new directory and main.rs in its src subdirectory. The manifest pins v0.15.0, whose minimum Rust version is 1.95. BACnet/SC’s TLS library (aws-lc) needs CMake and a C toolchain to build.
[package]name = "sc-operator-policy"version = "0.1.0"edition = "2024"rust-version = "1.95"publish = false
[workspace]
[dependencies]bacnet-server = { version = "=0.15.0", features = ["sc-tls"] }bacnet-transport = "=0.15.0"bacnet-types = "=0.15.0"//! Remote writes and DeviceCommunicationControl from one certificate name only.//!//! The policy allows a covered mutation (WriteProperty, CreateObject,//! DeleteObject and the rest) or a DeviceCommunicationControl request only//! when it arrived over a verified direct BACnet/SC connection from the peer//! that originated it, and that peer's certificate carries a DNS Subject//! Alternative Name equal to the configured one. Every other request is//! denied: B/IP, MS/TP and the other links, hub-relayed and routed requests//! carry no certificate.//!//! Usage: `BACNET_DCC_PASSWORD=... sc-operator-policy <operator-dns-name>`#![forbid(unsafe_code)]
use std::io::{self, Write};use std::process::ExitCode;use std::sync::Arc;
use bacnet_server::ServerPolicy;use bacnet_server::device_communication_control::DeviceCommunicationControlAuthorizationContext;use bacnet_server::mutation::MutationAuthorizationContext;use bacnet_server::policy::{ScPeerCertificate, ScSubjectAltName};use bacnet_server::server::{DccPolicy, DccState, ServerConfig};use bacnet_transport::port::TransportProvenance;use bacnet_types::MacAddr;
/// Allows writes and DCC only from a certificate that names `operator`.struct OperatorOnly { operator: String,}
impl OperatorOnly { /// Whether `certificate` carries a DNS name equal to the operator's: /// exact equality, with no case folding, wildcard or suffix matching. fn names_operator(&self, certificate: Option<&ScPeerCertificate>) -> bool { certificate .and_then(ScPeerCertificate::metadata) .is_some_and(|metadata| { metadata.subject_alt_names().iter().any(|name| match name { ScSubjectAltName::Dns(dns) => *dns == self.operator, _ => false, }) }) }}
impl ServerPolicy for OperatorOnly { fn authorize_mutation(&self, context: &MutationAuthorizationContext) -> bool { self.names_operator(context.certificate()) }
fn authorize_device_communication_control( &self, context: &DeviceCommunicationControlAuthorizationContext, ) -> bool { self.names_operator(context.certificate()) }}
fn main() -> Result<ExitCode, Box<dyn std::error::Error>> { let mut args = std::env::args().skip(1); let password = std::env::var("BACNET_DCC_PASSWORD").unwrap_or_default(); let (Some(operator), None, false) = (args.next(), args.next(), password.is_empty()) else { writeln!( io::stderr(), "usage: BACNET_DCC_PASSWORD=... sc-operator-policy <operator-dns-name>" )?; return Ok(ExitCode::from(2)); }; let policy = Arc::new(OperatorOnly { operator });
// Pass this configuration to BACnetServer::start with the SC transport // that owns your direct listener; a hub-only server never sees a // certificate. The static DCC settings run before the policy: the // default, DccPolicy::DenyAll, refuses every request without asking it. let _config = ServerConfig { policy: Some(policy.clone()), dcc_policy: DccPolicy::RequirePassword, dcc_password: Some(password), ..ServerConfig::default() };
// A request with no verified direct SC certificate, as from B/IP, // MS/TP, a hub relay or a router, is denied. let unverified = DeviceCommunicationControlAuthorizationContext { source_mac: MacAddr::from_slice(&[192, 0, 2, 10, 0xBA, 0xC0]), source_network: None, provenance: TransportProvenance::unverified(), invoke_id: 1, requested_state: DccState::DisableInitiation, duration_minutes: Some(5), }; if policy.authorize_device_communication_control(&unverified) { return Err("a request without a certificate was allowed".into()); } writeln!(io::stdout(), "DCC without a certificate: denied")?; Ok(ExitCode::SUCCESS)}Run it
Section titled “Run it”From the directory containing Cargo.toml, give the DCC password in the environment and the operator’s DNS name as the only argument:
BACNET_DCC_PASSWORD='replace-me' cargo run -- operator.example.comA successful run prints:
DCC without a certificate: deniedWithout a name, or without a password, it prints a usage message and exits unsuccessfully. CI compiles this exact file and runs it both ways in the sc_operator_policy integration tests. Those tests don’t exercise the allow path. The server’s own tests do, over real direct TLS: a renewed certificate with the same DNS name stays allowed.
Next steps
Section titled “Next steps”Configure BACnet/SC · Compose a shared endpoint · Support and conformance
Release v0.15.0 ·Build and integrate. Pre-1.0 APIs; partial conformance.Support & limitations