Skip to main content

ignition_core/deploy/
rules.rs

1//! Fixed render policy: which resource types and files a declared tree may
2//! never ship, however the manifest allowlist reads (Phase 21, DRND-04).
3
4/// `(module, type)` pairs that render refuses even when the manifest lists
5/// them. Rendering these can lock an administrator out of the gateway
6/// (replacing the security level or property singletons, the user sources, or
7/// the API tokens that tooling authenticates with), so they stay
8/// gateway-owned. Spellings are pinned to the OpenAPI extract by the
9/// `denied_types_exist_in_openapi_extract` test.
10pub const DENIED_TYPES: &[(&str, &str)] = &[
11    ("ignition", "security-properties"),
12    ("ignition", "security-levels"),
13    ("ignition", "api-token"),
14    ("ignition", "user-source"),
15];
16
17/// File names (final path component) the gateway writes itself, so a
18/// declared tree must not carry them. `config-mode.json` is written by the
19/// gateway's mode API; shipping one would overwrite the gateway's mode.
20pub const GATEWAY_OWNED_FILES: &[&str] = &["config-mode.json"];
21
22/// Whether `module/ty` is on the deny list.
23pub fn is_denied(module: &str, ty: &str) -> bool {
24    DENIED_TYPES.iter().any(|(m, t)| *m == module && *t == ty)
25}
26
27/// How a resource type carries its identity (`attributes.uuid`) and where its
28/// files sit. The three classes come from the corpus census (RESEARCH
29/// Pattern 4) and the 8.3.6 captures (decision CD3).
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum UuidRule {
32    /// One resource per type: files sit directly in `<module>/<type>/`, and
33    /// its `resource.json` carries no uuid.
34    Singleton,
35    /// Many resources per type, one directory each, every one with an
36    /// `attributes.uuid`. Render derives one for a resource that has none.
37    Named,
38    /// Named, but the corpus resources of this type carry no uuid
39    /// (`ignition/store-and-forward-engine`,
40    /// `com.inductiveautomation.perspective/themes`). Render never
41    /// synthesizes one (RESEARCH Pitfall 5); an existing uuid is kept, because
42    /// CD3 saw an 8.3.6 gateway write one for store-and-forward-engine.
43    NamedNoUuid,
44}
45
46/// The render rule for one resource type.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct TypeRule {
49    /// Module id.
50    pub module: &'static str,
51    /// Resource type.
52    pub ty: &'static str,
53    /// Identity class.
54    pub uuid: UuidRule,
55    /// The `scope` a synthesized `resource.json` carries.
56    pub scope: &'static str,
57}
58
59const fn row(
60    module: &'static str,
61    ty: &'static str,
62    uuid: UuidRule,
63    scope: &'static str,
64) -> TypeRule {
65    TypeRule {
66        module,
67        ty,
68        uuid,
69        scope,
70    }
71}
72
73/// Every resource type render accepts, sorted by (module, type) bytes. A type
74/// without a row is refused (fail-closed): only evidence-backed types render.
75///
76/// Evidence per row is the comment above it: `corpus` is the sanitized
77/// gateway corpus census (`tests/fixtures/deploy/corpus`, checked by
78/// `type_rules_agree_with_corpus`), `CD3` the 8.3.6 live captures in
79/// `21-LIVE-CAPTURES.md`. Denied types ([`DENIED_TYPES`]) have no row. The
80/// four tag types are not here: they are governed by the `tags/` layout
81/// (CD5), whose files use `unary-resource.json` and attribute shapes the
82/// three classes do not describe.
83pub const TYPE_RULES: &[TypeRule] = &[
84    // corpus
85    row(
86        "com.cirruslink.mqtt.distributor.gateway",
87        "general",
88        UuidRule::Singleton,
89        "A",
90    ),
91    // corpus
92    row(
93        "com.cirruslink.mqtt.distributor.gateway",
94        "user",
95        UuidRule::Named,
96        "A",
97    ),
98    // corpus
99    row(
100        "com.cirruslink.mqtt.engine.gateway",
101        "default-namespace",
102        UuidRule::Named,
103        "A",
104    ),
105    // corpus
106    row(
107        "com.cirruslink.mqtt.engine.gateway",
108        "general",
109        UuidRule::Singleton,
110        "A",
111    ),
112    // corpus
113    row(
114        "com.cirruslink.mqtt.engine.gateway",
115        "namespace-file",
116        UuidRule::Singleton,
117        "A",
118    ),
119    // corpus
120    row(
121        "com.cirruslink.mqtt.engine.gateway",
122        "namespace-server-set",
123        UuidRule::Named,
124        "A",
125    ),
126    // corpus
127    row(
128        "com.cirruslink.mqtt.engine.gateway",
129        "server",
130        UuidRule::Named,
131        "A",
132    ),
133    // corpus
134    row(
135        "com.cirruslink.mqtt.engine.gateway",
136        "server-set",
137        UuidRule::Named,
138        "A",
139    ),
140    // corpus
141    row(
142        "com.cirruslink.mqtt.transmission.gateway",
143        "general",
144        UuidRule::Singleton,
145        "A",
146    ),
147    // corpus
148    row(
149        "com.cirruslink.mqtt.transmission.gateway",
150        "history-store",
151        UuidRule::Named,
152        "A",
153    ),
154    // corpus
155    row(
156        "com.cirruslink.mqtt.transmission.gateway",
157        "server",
158        UuidRule::Named,
159        "A",
160    ),
161    // corpus
162    row(
163        "com.cirruslink.mqtt.transmission.gateway",
164        "server-set",
165        UuidRule::Named,
166        "A",
167    ),
168    // corpus
169    row(
170        "com.cirruslink.mqtt.transmission.gateway",
171        "transmitter",
172        UuidRule::Named,
173        "A",
174    ),
175    // CD3 (captured on 8.3.6)
176    row(
177        "com.inductiveautomation.alarm-notification",
178        "alarm-notification-profile",
179        UuidRule::Named,
180        "A",
181    ),
182    // corpus
183    row(
184        "com.inductiveautomation.eam",
185        "agent-group",
186        UuidRule::Named,
187        "A",
188    ),
189    // corpus
190    row(
191        "com.inductiveautomation.eam",
192        "event-thresholds",
193        UuidRule::Singleton,
194        "A",
195    ),
196    // corpus
197    row(
198        "com.inductiveautomation.eam",
199        "module-certificates",
200        UuidRule::Named,
201        "A",
202    ),
203    // corpus
204    row(
205        "com.inductiveautomation.eam",
206        "module-eulas",
207        UuidRule::Named,
208        "A",
209    ),
210    // corpus
211    row(
212        "com.inductiveautomation.eam",
213        "module-settings",
214        UuidRule::Singleton,
215        "A",
216    ),
217    // corpus
218    row(
219        "com.inductiveautomation.historian",
220        "historian-provider",
221        UuidRule::Named,
222        "A",
223    ),
224    // corpus
225    row(
226        "com.inductiveautomation.opcua",
227        "access-control",
228        UuidRule::Singleton,
229        "A",
230    ),
231    // corpus
232    row(
233        "com.inductiveautomation.opcua",
234        "device",
235        UuidRule::Named,
236        "A",
237    ),
238    // corpus
239    row(
240        "com.inductiveautomation.opcua",
241        "one-time",
242        UuidRule::Singleton,
243        "A",
244    ),
245    // corpus
246    row(
247        "com.inductiveautomation.opcua",
248        "server-config",
249        UuidRule::Singleton,
250        "A",
251    ),
252    // corpus
253    row(
254        "com.inductiveautomation.perspective",
255        "themes",
256        UuidRule::NamedNoUuid,
257        "G",
258    ),
259    // corpus
260    row(
261        "com.inductiveautomation.sfc",
262        "chart-settings",
263        UuidRule::Singleton,
264        "A",
265    ),
266    // corpus
267    row("ignition", "alarm-journal", UuidRule::Named, "A"),
268    // corpus
269    row("ignition", "audit-profile", UuidRule::Named, "A"),
270    // corpus
271    row("ignition", "cobranding", UuidRule::Singleton, "A"),
272    // corpus
273    row("ignition", "database-connection", UuidRule::Named, "A"),
274    // corpus
275    row("ignition", "database-driver", UuidRule::Named, "A"),
276    // corpus; CD1 and CD3 (captured on 8.3.6)
277    row("ignition", "database-translator", UuidRule::Named, "A"),
278    // corpus
279    row("ignition", "edge-sync-settings", UuidRule::Singleton, "A"),
280    // corpus
281    row(
282        "ignition",
283        "edge-system-properties",
284        UuidRule::Singleton,
285        "A",
286    ),
287    // corpus
288    row("ignition", "email-profile", UuidRule::Named, "A"),
289    // corpus
290    row(
291        "ignition",
292        "gateway-network-proxy-rules",
293        UuidRule::Singleton,
294        "A",
295    ),
296    // corpus
297    row(
298        "ignition",
299        "gateway-network-queue-settings",
300        UuidRule::Singleton,
301        "A",
302    ),
303    // corpus
304    row(
305        "ignition",
306        "gateway-network-settings",
307        UuidRule::Singleton,
308        "A",
309    ),
310    // corpus
311    row(
312        "ignition",
313        "general-alarm-settings",
314        UuidRule::Singleton,
315        "A",
316    ),
317    // CD3 (captured on 8.3.6)
318    row("ignition", "holiday", UuidRule::Named, "A"),
319    // corpus
320    row("ignition", "identity-provider", UuidRule::Named, "A"),
321    // CD3 (captured on 8.3.6)
322    row("ignition", "oauth2-client", UuidRule::Named, "A"),
323    // corpus
324    row("ignition", "opc-connection", UuidRule::Named, "A"),
325    // corpus
326    row("ignition", "quickstart", UuidRule::Singleton, "A"),
327    // CD3 (captured on 8.3.6)
328    row("ignition", "roster-config", UuidRule::Named, "A"),
329    // corpus
330    row("ignition", "schedule", UuidRule::Named, "A"),
331    // corpus
332    row("ignition", "security-zone", UuidRule::Named, "A"),
333    // CD3 (captured on 8.3.6)
334    row("ignition", "service-connector", UuidRule::Named, "A"),
335    // corpus
336    row(
337        "ignition",
338        "store-and-forward-engine",
339        UuidRule::NamedNoUuid,
340        "G",
341    ),
342    // corpus
343    row("ignition", "system-properties", UuidRule::Singleton, "A"),
344];
345
346/// The rule for `module/ty`, if the type has evidence.
347pub fn type_rule(module: &str, ty: &str) -> Option<&'static TypeRule> {
348    TYPE_RULES
349        .binary_search_by(|r| {
350            r.module
351                .as_bytes()
352                .cmp(module.as_bytes())
353                .then_with(|| r.ty.as_bytes().cmp(ty.as_bytes()))
354        })
355        .ok()
356        .map(|i| &TYPE_RULES[i])
357}
358
359/// The secret-typed config fields of one resource type, from the 8.3.6
360/// OpenAPI (`tests/fixtures/deploy/openapi-8.3.6-resource-types.json`,
361/// `secret_fields`; Phase 21, plan 21-01). Each path is dotted from the root
362/// of the type's `config.json`.
363#[derive(Debug, Clone, Copy, PartialEq, Eq)]
364pub struct SecretFields {
365    /// Module id.
366    pub module: &'static str,
367    /// Resource type.
368    pub ty: &'static str,
369    /// Dotted member paths whose value must be absent, null or Referenced.
370    pub paths: &'static [&'static str],
371}
372
373/// The ten resource types whose 8.3.6 schema has secret-typed fields, sorted
374/// by (module, type) bytes. `secret_fields_agree_with_openapi_extract` pins
375/// the table to the extract. The email-profile path is
376/// `settings.settings.password` (corrected from RESEARCH Pattern 7 in 21-01;
377/// the corpus `WH_Classic_SMTP/config.json` has the nested layout).
378pub const SECRET_FIELDS: &[SecretFields] = &[
379    SecretFields {
380        module: "com.inductiveautomation.alarm-notification",
381        ty: "alarm-notification-profile",
382        paths: &[
383            "settings.settings.password",
384            "settings.twoWayPop3.pop3Password",
385            "settings.voipGateway.password",
386        ],
387    },
388    SecretFields {
389        module: "com.inductiveautomation.opcua",
390        ty: "device",
391        paths: &["settings.advanced.password"],
392    },
393    SecretFields {
394        module: "ignition",
395        ty: "database-connection",
396        paths: &["password"],
397    },
398    SecretFields {
399        module: "ignition",
400        ty: "email-profile",
401        paths: &["settings.settings.password"],
402    },
403    SecretFields {
404        module: "ignition",
405        ty: "identity-provider",
406        paths: &["settings.clientSecret"],
407    },
408    SecretFields {
409        module: "ignition",
410        ty: "oauth2-client",
411        paths: &["clientSecret"],
412    },
413    SecretFields {
414        module: "ignition",
415        ty: "opc-connection",
416        paths: &[
417            "settings.authentication.password",
418            "settings.authentication.privateKeyPem",
419            "settings.security.keyStoreAliasPassword",
420        ],
421    },
422    SecretFields {
423        module: "ignition",
424        ty: "service-connector",
425        paths: &[
426            "settings.password",
427            "settings.ssl.keyPassword",
428            "settings.ssl.keystore.key",
429            "settings.ssl.keystore.password",
430            "settings.ssl.truststore.password",
431        ],
432    },
433    SecretFields {
434        module: "ignition",
435        ty: "system-properties",
436        paths: &["errorReportPassword"],
437    },
438    SecretFields {
439        module: "ignition",
440        ty: "user-source",
441        paths: &[
442            "settings.connectionPassword",
443            "settings.ldap.connectionPassword",
444        ],
445    },
446];
447
448/// The secret-typed paths of `module/ty`; empty when the type has none (or is
449/// not described by the OpenAPI at all).
450pub fn secret_paths(module: &str, ty: &str) -> &'static [&'static str] {
451    SECRET_FIELDS
452        .iter()
453        .find(|s| s.module == module && s.ty == ty)
454        .map_or(&[], |s| s.paths)
455}
456
457/// Every (module, type) pair the 8.3.6 OpenAPI describes, sorted by bytes.
458/// `openapi_types_equal_extract` pins it to the extract, so it cannot drift
459/// from the evidence. A type outside this list (for example the Cirrus Link
460/// MQTT types) has no schema, so secret detection there falls back to the
461/// generic Embedded scan and the key-name rule (21-08).
462pub const OPENAPI_TYPES: &[(&str, &str)] = &[
463    (
464        "com.inductiveautomation.alarm-notification",
465        "alarm-notification-profile",
466    ),
467    ("com.inductiveautomation.eam", "agent-group"),
468    ("com.inductiveautomation.eam", "agent-management"),
469    ("com.inductiveautomation.eam", "eam-tasks"),
470    ("com.inductiveautomation.eam", "event-thresholds"),
471    ("com.inductiveautomation.eam", "hw-license-management"),
472    ("com.inductiveautomation.eam", "leased-license-management"),
473    ("com.inductiveautomation.eam", "module-certificates"),
474    ("com.inductiveautomation.eam", "module-eulas"),
475    ("com.inductiveautomation.eam", "module-settings"),
476    ("com.inductiveautomation.eam", "remote-upgrade"),
477    ("com.inductiveautomation.historian", "historian-provider"),
478    ("com.inductiveautomation.opcua", "access-control"),
479    ("com.inductiveautomation.opcua", "device"),
480    ("com.inductiveautomation.opcua", "server-config"),
481    (
482        "com.inductiveautomation.opcua.drivers.bacnet",
483        "BacnetIpLocalDeviceConfig",
484    ),
485    ("com.inductiveautomation.perspective", "fonts"),
486    ("com.inductiveautomation.perspective", "icons"),
487    ("com.inductiveautomation.perspective", "themes"),
488    ("com.inductiveautomation.sfc", "chart-settings"),
489    (
490        "com.inductiveautomation.sip-notification",
491        "script-settings",
492    ),
493    ("ignition", "alarm-journal"),
494    ("ignition", "api-token"),
495    ("ignition", "audit-profile"),
496    ("ignition", "cobranding"),
497    ("ignition", "database-connection"),
498    ("ignition", "database-driver"),
499    ("ignition", "database-translator"),
500    ("ignition", "edge-system-properties"),
501    ("ignition", "email-profile"),
502    ("ignition", "gateway-network-incoming"),
503    ("ignition", "gateway-network-outgoing"),
504    ("ignition", "gateway-network-proxy-rules"),
505    ("ignition", "gateway-network-queue-settings"),
506    ("ignition", "gateway-network-settings"),
507    ("ignition", "general-alarm-settings"),
508    ("ignition", "holiday"),
509    ("ignition", "identity-provider"),
510    ("ignition", "keyboard_layout"),
511    ("ignition", "local-system-properties"),
512    ("ignition", "metrics-dashboard"),
513    ("ignition", "oauth2-client"),
514    ("ignition", "opc-connection"),
515    ("ignition", "quickstart"),
516    ("ignition", "roster-config"),
517    ("ignition", "schedule"),
518    ("ignition", "secret-provider"),
519    ("ignition", "security-levels"),
520    ("ignition", "security-properties"),
521    ("ignition", "security-zone"),
522    ("ignition", "service-connector"),
523    ("ignition", "store-and-forward-engine"),
524    ("ignition", "system-properties"),
525    ("ignition", "tag-provider"),
526    ("ignition", "translations"),
527    ("ignition", "user-source"),
528];
529
530/// Whether the 8.3.6 OpenAPI describes `module/ty`: the schema is
531/// authoritative for its secret-typed fields.
532pub fn openapi_describes(module: &str, ty: &str) -> bool {
533    OPENAPI_TYPES.binary_search(&(module, ty)).is_ok()
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539
540    const OPENAPI: &str = include_str!(concat!(
541        env!("CARGO_MANIFEST_DIR"),
542        "/tests/fixtures/deploy/openapi-8.3.6-resource-types.json"
543    ));
544
545    #[test]
546    fn deny_list_is_exact() {
547        assert!(is_denied("ignition", "security-properties"));
548        assert!(is_denied("ignition", "user-source"));
549        assert!(!is_denied("ignition", "database-translator"));
550        assert!(!is_denied("other", "api-token"));
551    }
552
553    #[test]
554    fn type_rules_are_sorted_unique_and_exclude_denied() {
555        for pair in TYPE_RULES.windows(2) {
556            let a = (pair[0].module.as_bytes(), pair[0].ty.as_bytes());
557            let b = (pair[1].module.as_bytes(), pair[1].ty.as_bytes());
558            assert!(
559                a < b,
560                "not strictly sorted: {}/{} then {}/{}",
561                pair[0].module,
562                pair[0].ty,
563                pair[1].module,
564                pair[1].ty
565            );
566        }
567        for r in TYPE_RULES {
568            assert!(
569                !is_denied(r.module, r.ty),
570                "{}/{} is denied",
571                r.module,
572                r.ty
573            );
574            assert!(
575                matches!(r.scope, "A" | "G"),
576                "{}/{} scope {}",
577                r.module,
578                r.ty,
579                r.scope
580            );
581            assert_eq!(type_rule(r.module, r.ty), Some(r));
582            if r.uuid == UuidRule::NamedNoUuid {
583                assert_eq!(r.scope, "G", "{}/{}", r.module, r.ty);
584            }
585        }
586        assert_eq!(type_rule("ignition", "no-such-type"), None);
587        assert_eq!(type_rule("ignition", "security-levels"), None);
588        // the two no-uuid types, and no others
589        let no_uuid: Vec<_> = TYPE_RULES
590            .iter()
591            .filter(|r| r.uuid == UuidRule::NamedNoUuid)
592            .map(|r| (r.module, r.ty))
593            .collect();
594        assert_eq!(
595            no_uuid,
596            [
597                ("com.inductiveautomation.perspective", "themes"),
598                ("ignition", "store-and-forward-engine")
599            ]
600        );
601    }
602
603    #[test]
604    fn type_rules_agree_with_openapi_extract() {
605        let doc: serde_json::Value = serde_json::from_str(OPENAPI).expect("extract parses");
606        let types = doc["types"].as_array().expect("types array");
607        // RESEARCH was corrected from 87/49 to 56 types: 39 named, 17 singleton
608        assert_eq!(types.len(), 56);
609        assert_eq!(types.iter().filter(|t| t["named"] == true).count(), 39);
610        let mut compared = 0;
611        for t in types {
612            let (module, ty) = (
613                t["module"].as_str().expect("module"),
614                t["type"].as_str().expect("type"),
615            );
616            let Some(rule) = type_rule(module, ty) else {
617                continue;
618            };
619            compared += 1;
620            let named = t["named"].as_bool().expect("named flag");
621            assert_eq!(
622                named,
623                rule.uuid != UuidRule::Singleton,
624                "{module}/{ty}: extract says named={named}, rule says {:?}",
625                rule.uuid
626            );
627        }
628        assert!(compared > 25, "only {compared} rows were comparable");
629    }
630
631    #[test]
632    fn secret_fields_agree_with_openapi_extract() {
633        let doc: serde_json::Value = serde_json::from_str(OPENAPI).expect("extract parses");
634        let fields = doc["secret_fields"].as_array().expect("secret_fields");
635        assert_eq!(fields.len(), SECRET_FIELDS.len());
636        assert_eq!(SECRET_FIELDS.len(), 10);
637        for (row, want) in SECRET_FIELDS.iter().zip(fields) {
638            assert_eq!(row.module, want["module"].as_str().expect("module"));
639            assert_eq!(row.ty, want["type"].as_str().expect("type"));
640            let paths: Vec<&str> = want["paths"]
641                .as_array()
642                .expect("paths")
643                .iter()
644                .map(|p| p.as_str().expect("path"))
645                .collect();
646            assert_eq!(row.paths, paths.as_slice(), "{}/{}", row.module, row.ty);
647        }
648        for pair in SECRET_FIELDS.windows(2) {
649            assert!(
650                (pair[0].module.as_bytes(), pair[0].ty.as_bytes())
651                    < (pair[1].module.as_bytes(), pair[1].ty.as_bytes())
652            );
653        }
654        assert_eq!(
655            secret_paths("ignition", "email-profile"),
656            ["settings.settings.password"]
657        );
658        assert!(secret_paths("ignition", "schedule").is_empty());
659        assert!(secret_paths("no.such", "type").is_empty());
660        // every secret-bearing type is a described type
661        for row in SECRET_FIELDS {
662            assert!(openapi_describes(row.module, row.ty));
663        }
664    }
665
666    #[test]
667    fn openapi_types_equal_extract() {
668        let doc: serde_json::Value = serde_json::from_str(OPENAPI).expect("extract parses");
669        let mut want: Vec<(String, String)> = doc["types"]
670            .as_array()
671            .expect("types")
672            .iter()
673            .map(|t| {
674                (
675                    t["module"].as_str().expect("module").to_string(),
676                    t["type"].as_str().expect("type").to_string(),
677                )
678            })
679            .collect();
680        want.sort_by(|a, b| {
681            (a.0.as_bytes(), a.1.as_bytes()).cmp(&(b.0.as_bytes(), b.1.as_bytes()))
682        });
683        let got: Vec<(String, String)> = OPENAPI_TYPES
684            .iter()
685            .map(|(m, t)| ((*m).to_string(), (*t).to_string()))
686            .collect();
687        assert_eq!(got, want);
688        assert_eq!(OPENAPI_TYPES.len(), 56);
689        assert!(openapi_describes("ignition", "database-connection"));
690        assert!(!openapi_describes(
691            "com.cirruslink.mqtt.engine.gateway",
692            "server"
693        ));
694    }
695}