Skip to main content

reddb_server/cli/
bootstrap_manifest.rs

1//! First-boot bootstrap manifest support.
2//!
3//! The manifest is applied only before `system.bootstrap.completed` is
4//! persisted by the server bootstrap path. It is intentionally a thin
5//! translation layer over existing public surfaces: `AuthStore` for users
6//! and IAM policies, `ConfigRegistry` for managed guardrails, and
7//! `red_config` rows for config values.
8
9use std::collections::{HashMap, HashSet};
10use std::path::Path;
11use std::sync::Arc;
12
13use crate::auth::policies::{evaluate, Decision, EvalContext, Policy, ResourceRef};
14use crate::auth::registry::{
15    ConfigRegistry, ConfigRegistryDraft, ConfigRegistryEntry, EvidenceRequirement, Mutability,
16    Sensitivity, ACTION_REGISTER, RESOURCE_KIND,
17};
18use crate::auth::store::{AuthStore, PrincipalRef};
19use crate::auth::{Role, User, UserId};
20use crate::runtime::RedDBRuntime;
21use crate::serde_json::{Map as JsonMap, Value as JsonValue};
22use crate::service_cli::BootstrapConfig;
23use crate::storage::schema::Value;
24use crate::storage::unified::{EntityData, EntityId, EntityKind, RowData, UnifiedEntity};
25
26pub const MANIFEST_ENV: &str = "REDDB_BOOTSTRAP_MANIFEST";
27const REGISTRY_STATE_KEY: &str = "system.bootstrap.manifest.registry_entries";
28
29pub fn apply_manifest_file(
30    runtime: &RedDBRuntime,
31    auth_store: &Arc<AuthStore>,
32    registry: &Arc<ConfigRegistry>,
33    path: &Path,
34) -> Result<String, String> {
35    let raw = std::fs::read_to_string(path)
36        .map_err(|err| format!("read {MANIFEST_ENV} {}: {err}", path.display()))?;
37    let manifest = BootstrapManifest::parse(&raw)?;
38    manifest.apply(runtime, auth_store, registry)
39}
40
41pub(crate) fn bootstrap_config_from_manifest_file(
42    path: &Path,
43) -> Result<Option<BootstrapConfig>, String> {
44    let raw = std::fs::read_to_string(path)
45        .map_err(|err| format!("read {MANIFEST_ENV} {}: {err}", path.display()))?;
46    bootstrap_config_from_manifest_json(&raw)
47}
48
49pub fn rehydrate_manifest_registry(
50    runtime: &RedDBRuntime,
51    registry: &Arc<ConfigRegistry>,
52) -> Result<(), String> {
53    let Some(value) = runtime.db().store().get_config(REGISTRY_STATE_KEY) else {
54        return Ok(());
55    };
56    let json = match value {
57        Value::Json(bytes) => crate::serde_json::from_slice::<JsonValue>(&bytes)
58            .map_err(|err| format!("parse persisted bootstrap registry: {err}"))?,
59        Value::Text(s) => crate::serde_json::from_str::<JsonValue>(s.as_ref())
60            .map_err(|err| format!("parse persisted bootstrap registry: {err}"))?,
61        other => {
62            return Err(format!(
63                "persisted bootstrap registry must be JSON, got {other:?}"
64            ));
65        }
66    };
67    let entries = json
68        .as_array()
69        .ok_or_else(|| "persisted bootstrap registry must be an array".to_string())?;
70    for (idx, value) in entries.iter().enumerate() {
71        let entry = registry_entry_from_json(value, idx)?;
72        registry
73            .restore_bootstrap_entry(entry)
74            .map_err(|err| format!("restore bootstrap registry entry {idx}: {err}"))?;
75    }
76    Ok(())
77}
78
79fn bootstrap_config_from_manifest_json(raw: &str) -> Result<Option<BootstrapConfig>, String> {
80    let root: JsonValue =
81        crate::serde_json::from_str(raw).map_err(|err| format!("parse manifest JSON: {err}"))?;
82    let obj = root
83        .as_object()
84        .ok_or_else(|| "bootstrap manifest must be a JSON object".to_string())?;
85    let intent = obj
86        .get("bootstrap")
87        .and_then(|value| value.as_object())
88        .unwrap_or(obj);
89
90    let has_intent = intent.contains_key("preset")
91        || intent.contains_key("bootstrap_preset")
92        || intent.contains_key("admin")
93        || intent.contains_key("bootstrap_admin")
94        || intent.contains_key("cloud_head_admin")
95        || intent.contains_key("customer_admin");
96    if !has_intent {
97        return Ok(None);
98    }
99
100    let admin = parse_bootstrap_principal(intent, "admin", "bootstrap_admin")?;
101    let cloud_head = parse_bootstrap_principal(intent, "cloud_head_admin", "cloud_head_admin")?;
102    let customer = parse_bootstrap_principal(intent, "customer_admin", "customer_admin")?;
103
104    Ok(Some(BootstrapConfig {
105        preset: optional_string(intent, "preset")
106            .or_else(|| optional_string(intent, "bootstrap_preset")),
107        manifest: None,
108        admin_username: admin.as_ref().map(|principal| principal.username.clone()),
109        admin_password: admin.map(|principal| principal.password),
110        cloud_head_admin: cloud_head
111            .as_ref()
112            .map(|principal| principal.username.clone()),
113        cloud_head_admin_password: cloud_head.map(|principal| principal.password),
114        customer_admin: customer
115            .as_ref()
116            .map(|principal| principal.username.clone()),
117        customer_admin_password: customer.map(|principal| principal.password),
118        ..BootstrapConfig::default()
119    }))
120}
121
122struct BootstrapPrincipal {
123    username: String,
124    password: String,
125}
126
127fn parse_bootstrap_principal(
128    obj: &JsonMap<String, JsonValue>,
129    field: &str,
130    alias: &str,
131) -> Result<Option<BootstrapPrincipal>, String> {
132    let Some(value) = obj.get(field).or_else(|| obj.get(alias)) else {
133        return Ok(None);
134    };
135    let principal = value
136        .as_object()
137        .ok_or_else(|| format!("bootstrap manifest `{field}` must be an object"))?;
138    if principal.contains_key("password") || principal.contains_key("secret") {
139        return Err(format!(
140            "bootstrap manifest `{field}` must use password_file, not inline plaintext"
141        ));
142    }
143    let username = optional_string(principal, "username")
144        .or_else(|| optional_string(principal, "name"))
145        .ok_or_else(|| format!("bootstrap manifest `{field}` requires username"))?;
146    let password_file = optional_string(principal, "password_file")
147        .or_else(|| optional_string(principal, "password_path"))
148        .ok_or_else(|| format!("bootstrap manifest `{field}` requires password_file"))?;
149    let password = read_secret_file(&password_file, field)?;
150    Ok(Some(BootstrapPrincipal { username, password }))
151}
152
153fn read_secret_file(path: &str, field: &str) -> Result<String, String> {
154    let trimmed_path = path.trim();
155    if trimmed_path.is_empty() {
156        return Err(format!(
157            "bootstrap manifest `{field}` password_file is empty"
158        ));
159    }
160    let value = std::fs::read_to_string(trimmed_path)
161        .map_err(|err| format!("read bootstrap manifest `{field}` password_file: {err}"))?
162        .trim_end_matches(['\n', '\r'])
163        .to_string();
164    if value.is_empty() {
165        return Err(format!(
166            "bootstrap manifest `{field}` password_file produced an empty secret"
167        ));
168    }
169    Ok(value)
170}
171
172struct BootstrapManifest {
173    users: Vec<ManifestUser>,
174    policies: Vec<Policy>,
175    managed_policies: Vec<ManagedPolicy>,
176    attachments: Vec<PolicyAttachment>,
177    registry_entries: Vec<ManifestRegistryEntry>,
178    managed_config_namespaces: Vec<ManifestRegistryEntry>,
179    config: Vec<ManifestConfig>,
180    actor: String,
181}
182
183struct ManifestUser {
184    username: String,
185    password: String,
186    role: Role,
187    tenant: Option<String>,
188}
189
190struct ManagedPolicy {
191    policy: Policy,
192    required_resource: String,
193    evidence: EvidenceRequirement,
194}
195
196struct PolicyAttachment {
197    user: Option<String>,
198    group: Option<String>,
199    policy: String,
200}
201
202struct ManifestRegistryEntry {
203    id: String,
204    resource_type: String,
205    schema: String,
206    mutability: Mutability,
207    sensitivity: Sensitivity,
208    managed: bool,
209    required_action: String,
210    required_resource: String,
211    evidence: EvidenceRequirement,
212}
213
214struct ManifestConfig {
215    key: String,
216    value: Value,
217}
218
219impl BootstrapManifest {
220    fn parse(raw: &str) -> Result<Self, String> {
221        let root: JsonValue = crate::serde_json::from_str(raw)
222            .map_err(|err| format!("parse manifest JSON: {err}"))?;
223        let obj = root
224            .as_object()
225            .ok_or_else(|| "bootstrap manifest must be a JSON object".to_string())?;
226
227        let users = parse_users(array_field(obj, "users")?)?;
228        let policies = parse_policies(array_field(obj, "policies")?, "policies")?;
229        let managed_policies = parse_managed_policies(array_field(obj, "managed_policies")?)?;
230        let attachments = parse_attachments(array_field(obj, "attachments")?)?;
231        let mut registry_values = Vec::new();
232        registry_values.extend_from_slice(array_field(obj, "registry_entries")?);
233        registry_values.extend_from_slice(array_field(obj, "registry")?);
234        let registry_entries = parse_registry_entries(&registry_values)?;
235        let managed_config_namespaces =
236            parse_managed_config_namespaces(array_field(obj, "managed_config_namespaces")?)?;
237        let config = parse_config(array_field(obj, "config")?)?;
238        let actor = obj
239            .get("actor")
240            .and_then(|v| v.as_str())
241            .map(str::trim)
242            .filter(|s| !s.is_empty())
243            .map(str::to_string)
244            .or_else(|| users.first().map(|u| u.username.clone()))
245            .ok_or_else(|| "bootstrap manifest requires actor or at least one user".to_string())?;
246
247        let mut user_ids = HashSet::new();
248        for user in &users {
249            if !user_ids.insert(user_id(&user.tenant, &user.username)) {
250                return Err(format!("duplicate manifest user `{}`", user.username));
251            }
252        }
253
254        let mut policy_ids = HashSet::new();
255        for policy in policies
256            .iter()
257            .chain(managed_policies.iter().map(|p| &p.policy))
258        {
259            if !policy_ids.insert(policy.id.clone()) {
260                return Err(format!("duplicate manifest policy `{}`", policy.id));
261            }
262        }
263
264        for attachment in &attachments {
265            if !policy_ids.contains(&attachment.policy) {
266                return Err(format!(
267                    "policy attachment references unknown manifest policy `{}`",
268                    attachment.policy
269                ));
270            }
271            if let Some(user) = attachment.user.as_ref() {
272                if !user_ids.contains(&UserId::platform(user)) {
273                    return Err(format!(
274                        "policy attachment references unknown manifest user `{user}`"
275                    ));
276                }
277            }
278            if attachment.user.is_none() && attachment.group.is_none() {
279                return Err("policy attachment requires user or group".to_string());
280            }
281        }
282
283        validate_registry_authorization_plan(
284            &users,
285            &policies,
286            &managed_policies,
287            &attachments,
288            &registry_entries,
289            &managed_config_namespaces,
290            &actor,
291        )?;
292
293        Ok(Self {
294            users,
295            policies,
296            managed_policies,
297            attachments,
298            registry_entries,
299            managed_config_namespaces,
300            config,
301            actor,
302        })
303    }
304
305    fn apply(
306        &self,
307        runtime: &RedDBRuntime,
308        auth_store: &Arc<AuthStore>,
309        registry: &Arc<ConfigRegistry>,
310    ) -> Result<String, String> {
311        self.validate_against_current_state(auth_store, registry)?;
312
313        for user in &self.users {
314            auth_store
315                .create_user_in_tenant(
316                    user.tenant.as_deref(),
317                    &user.username,
318                    &user.password,
319                    user.role,
320                )
321                .map_err(|err| format!("create user `{}`: {err}", user.username))?;
322        }
323
324        for policy in &self.policies {
325            auth_store
326                .put_policy(policy.clone())
327                .map_err(|err| format!("install policy `{}`: {err}", policy.id))?;
328        }
329        for managed in &self.managed_policies {
330            auth_store
331                .put_policy(managed.policy.clone())
332                .map_err(|err| format!("install managed policy `{}`: {err}", managed.policy.id))?;
333        }
334        for attachment in &self.attachments {
335            let principal = match (&attachment.user, &attachment.group) {
336                (Some(user), None) => PrincipalRef::User(UserId::platform(user)),
337                (None, Some(group)) => PrincipalRef::Group(group.clone()),
338                (Some(_), Some(_)) => {
339                    return Err("policy attachment cannot specify both user and group".to_string());
340                }
341                (None, None) => return Err("policy attachment requires user or group".to_string()),
342            };
343            auth_store
344                .attach_policy(principal, &attachment.policy)
345                .map_err(|err| format!("attach policy `{}`: {err}", attachment.policy))?;
346        }
347
348        let (actor, actor_user) = self.actor(auth_store)?;
349        let ctx = registry_context(&actor_user);
350        let now_ms = current_unix_ms();
351
352        let mut registered = Vec::new();
353        for entry in &self.registry_entries {
354            registered.push(register_entry(
355                registry, auth_store, &actor, &ctx, entry, now_ms,
356            )?);
357        }
358        for managed in &self.managed_policies {
359            let entry = ManifestRegistryEntry {
360                id: managed.policy.id.clone(),
361                resource_type: crate::auth::managed_policy::RESOURCE_TYPE_POLICY.to_string(),
362                schema: "iam_policy".to_string(),
363                mutability: Mutability::Immutable,
364                sensitivity: Sensitivity::Internal,
365                managed: true,
366                required_action: "policy:*".to_string(),
367                required_resource: managed.required_resource.clone(),
368                evidence: managed.evidence,
369            };
370            registered.push(register_entry(
371                registry, auth_store, &actor, &ctx, &entry, now_ms,
372            )?);
373        }
374        for entry in &self.managed_config_namespaces {
375            registered.push(register_entry(
376                registry, auth_store, &actor, &ctx, entry, now_ms,
377            )?);
378        }
379
380        for config in &self.config {
381            insert_config_value_if_absent(runtime, &config.key, config.value.clone())?;
382        }
383        if !registered.is_empty() {
384            persist_registry_state(runtime, &registered)?;
385        }
386
387        Ok(actor.to_string())
388    }
389
390    fn validate_against_current_state(
391        &self,
392        auth_store: &AuthStore,
393        registry: &ConfigRegistry,
394    ) -> Result<(), String> {
395        for user in &self.users {
396            if auth_store
397                .get_user(user.tenant.as_deref(), &user.username)
398                .is_some()
399            {
400                return Err(format!("manifest user `{}` already exists", user.username));
401            }
402        }
403        for policy in self
404            .policies
405            .iter()
406            .chain(self.managed_policies.iter().map(|p| &p.policy))
407        {
408            if auth_store.get_policy(&policy.id).is_some() {
409                return Err(format!("manifest policy `{}` already exists", policy.id));
410            }
411        }
412        for entry in &self.registry_entries {
413            if registry.get_active(&entry.id).is_some() {
414                return Err(format!("registry entry `{}` already exists", entry.id));
415            }
416        }
417        for entry in &self.managed_config_namespaces {
418            if registry.get_active(&entry.id).is_some() {
419                return Err(format!("registry entry `{}` already exists", entry.id));
420            }
421        }
422        for managed in &self.managed_policies {
423            if registry.get_active(&managed.policy.id).is_some() {
424                return Err(format!(
425                    "registry entry `{}` already exists",
426                    managed.policy.id
427                ));
428            }
429        }
430        Ok(())
431    }
432
433    fn actor(&self, auth_store: &AuthStore) -> Result<(UserId, User), String> {
434        let actor = UserId::platform(&self.actor);
435        let user = auth_store
436            .get_user(None, &self.actor)
437            .ok_or_else(|| format!("manifest actor `{}` does not exist", self.actor))?;
438        Ok((actor, user))
439    }
440}
441
442fn parse_users(values: &[JsonValue]) -> Result<Vec<ManifestUser>, String> {
443    values
444        .iter()
445        .enumerate()
446        .map(|(idx, value)| {
447            let obj = object_at(value, "users", idx)?;
448            let username = required_string(obj, "username", "users", idx)?;
449            if obj.contains_key("password") && obj.contains_key("password_file") {
450                return Err(format!(
451                    "users[{idx}] must specify only one of password or password_file"
452                ));
453            }
454            let password = if let Some(path) = optional_string(obj, "password_file") {
455                read_secret_file(&path, "users.password_file")?
456            } else {
457                required_string(obj, "password", "users", idx)?
458            };
459            if password.is_empty() {
460                return Err(format!("users[{idx}].password is required"));
461            }
462            let role = Role::from_str(&required_string(obj, "role", "users", idx)?)
463                .ok_or_else(|| format!("users[{idx}].role must be read, write, or admin"))?;
464            if obj.contains_key("system_owned") {
465                return Err(format!(
466                    "users[{idx}].system_owned is no longer supported; use explicit policies"
467                ));
468            }
469            Ok(ManifestUser {
470                username,
471                password,
472                role,
473                tenant: optional_string(obj, "tenant"),
474            })
475        })
476        .collect()
477}
478
479fn parse_policies(values: &[JsonValue], field: &str) -> Result<Vec<Policy>, String> {
480    values
481        .iter()
482        .enumerate()
483        .map(|(idx, value)| {
484            let document = value
485                .as_object()
486                .and_then(|obj| obj.get("document"))
487                .unwrap_or(value);
488            Policy::from_json_str(&document.to_string_compact())
489                .map_err(|err| format!("{field}[{idx}] is not a valid policy: {err}"))
490        })
491        .collect()
492}
493
494fn parse_managed_policies(values: &[JsonValue]) -> Result<Vec<ManagedPolicy>, String> {
495    values
496        .iter()
497        .enumerate()
498        .map(|(idx, value)| {
499            let obj = object_at(value, "managed_policies", idx)?;
500            let document = obj.get("document").unwrap_or(value);
501            let policy = Policy::from_json_str(&document.to_string_compact())
502                .map_err(|err| format!("managed_policies[{idx}] is not a valid policy: {err}"))?;
503            Ok(ManagedPolicy {
504                required_resource: optional_string(obj, "required_resource")
505                    .unwrap_or_else(|| format!("policy:{}", policy.id)),
506                evidence: obj
507                    .get("evidence")
508                    .and_then(|v| v.as_str())
509                    .map(parse_evidence)
510                    .transpose()?
511                    .unwrap_or(EvidenceRequirement::Metadata),
512                policy,
513            })
514        })
515        .collect()
516}
517
518fn parse_attachments(values: &[JsonValue]) -> Result<Vec<PolicyAttachment>, String> {
519    values
520        .iter()
521        .enumerate()
522        .map(|(idx, value)| {
523            let obj = object_at(value, "attachments", idx)?;
524            Ok(PolicyAttachment {
525                user: optional_string(obj, "user"),
526                group: optional_string(obj, "group"),
527                policy: required_string(obj, "policy", "attachments", idx)?,
528            })
529        })
530        .collect()
531}
532
533fn parse_registry_entries(values: &[JsonValue]) -> Result<Vec<ManifestRegistryEntry>, String> {
534    values
535        .iter()
536        .enumerate()
537        .map(|(idx, value)| parse_registry_entry(value, "registry_entries", idx, None))
538        .collect()
539}
540
541fn parse_managed_config_namespaces(
542    values: &[JsonValue],
543) -> Result<Vec<ManifestRegistryEntry>, String> {
544    values
545        .iter()
546        .enumerate()
547        .map(|(idx, value)| {
548            parse_registry_entry(
549                value,
550                "managed_config_namespaces",
551                idx,
552                Some(crate::auth::managed_config::RESOURCE_TYPE_CONFIG_NAMESPACE),
553            )
554        })
555        .collect()
556}
557
558fn parse_registry_entry(
559    value: &JsonValue,
560    field: &str,
561    idx: usize,
562    forced_type: Option<&str>,
563) -> Result<ManifestRegistryEntry, String> {
564    let obj = object_at(value, field, idx)?;
565    let id = required_string(obj, "id", field, idx)?;
566    Ok(ManifestRegistryEntry {
567        resource_type: forced_type
568            .map(str::to_string)
569            .or_else(|| optional_string(obj, "resource_type"))
570            .ok_or_else(|| format!("{field}[{idx}].resource_type is required"))?,
571        schema: optional_string(obj, "schema").unwrap_or_else(|| "manifest".to_string()),
572        mutability: obj
573            .get("mutability")
574            .and_then(|v| v.as_str())
575            .map(parse_mutability)
576            .transpose()?
577            .unwrap_or(Mutability::Immutable),
578        sensitivity: obj
579            .get("sensitivity")
580            .and_then(|v| v.as_str())
581            .map(parse_sensitivity)
582            .transpose()?
583            .unwrap_or(Sensitivity::Internal),
584        managed: optional_bool(obj, "managed").unwrap_or(true),
585        required_action: optional_string(obj, "required_action")
586            .unwrap_or_else(|| "config:write".to_string()),
587        required_resource: optional_string(obj, "required_resource")
588            .unwrap_or_else(|| format!("config:{id}")),
589        evidence: obj
590            .get("evidence")
591            .and_then(|v| v.as_str())
592            .map(parse_evidence)
593            .transpose()?
594            .unwrap_or(EvidenceRequirement::Metadata),
595        id,
596    })
597}
598
599fn parse_config(values: &[JsonValue]) -> Result<Vec<ManifestConfig>, String> {
600    values
601        .iter()
602        .enumerate()
603        .map(|(idx, value)| {
604            let obj = object_at(value, "config", idx)?;
605            let key = required_string(obj, "key", "config", idx)?;
606            if key.trim().is_empty() {
607                return Err(format!("config[{idx}].key is required"));
608            }
609            if obj.contains_key("secret") || obj.contains_key("plaintext") {
610                return Err(format!(
611                    "config[{idx}] must not contain secret plaintext; use secret_ref"
612                ));
613            }
614            let value = if let Some(secret_ref) = obj.get("secret_ref") {
615                secret_ref_storage_value(secret_ref, idx)?
616            } else {
617                json_to_storage_value(
618                    obj.get("value")
619                        .ok_or_else(|| format!("config[{idx}].value or secret_ref is required"))?,
620                )?
621            };
622            Ok(ManifestConfig { key, value })
623        })
624        .collect()
625}
626
627fn validate_registry_authorization_plan(
628    users: &[ManifestUser],
629    policies: &[Policy],
630    managed_policies: &[ManagedPolicy],
631    attachments: &[PolicyAttachment],
632    registry_entries: &[ManifestRegistryEntry],
633    managed_config_namespaces: &[ManifestRegistryEntry],
634    actor: &str,
635) -> Result<(), String> {
636    let needs_registry = !registry_entries.is_empty()
637        || !managed_policies.is_empty()
638        || !managed_config_namespaces.is_empty();
639    if !needs_registry {
640        return Ok(());
641    }
642
643    let actor_user = users
644        .iter()
645        .find(|user| user.tenant.is_none() && user.username == actor)
646        .ok_or_else(|| format!("manifest actor `{actor}` must be declared as a platform user"))?;
647    let ctx = manifest_user_context(actor_user);
648    let policy_by_id: HashMap<&str, &Policy> = policies
649        .iter()
650        .chain(managed_policies.iter().map(|managed| &managed.policy))
651        .map(|policy| (policy.id.as_str(), policy))
652        .collect();
653    let actor_policies: Vec<&Policy> = attachments
654        .iter()
655        .filter(|attachment| attachment.user.as_deref() == Some(actor))
656        .filter_map(|attachment| policy_by_id.get(attachment.policy.as_str()).copied())
657        .collect();
658
659    let mut entry_ids: Vec<&str> = registry_entries
660        .iter()
661        .map(|entry| entry.id.as_str())
662        .collect();
663    entry_ids.extend(
664        managed_policies
665            .iter()
666            .map(|managed| managed.policy.id.as_str()),
667    );
668    entry_ids.extend(
669        managed_config_namespaces
670            .iter()
671            .map(|entry| entry.id.as_str()),
672    );
673
674    for id in entry_ids {
675        let resource = ResourceRef::new(RESOURCE_KIND, id);
676        if !matches!(
677            evaluate(&actor_policies, ACTION_REGISTER, &resource, &ctx),
678            Decision::Allow { .. }
679        ) {
680            return Err(format!(
681                "manifest actor `{actor}` must have an attached policy allowing \
682                 {ACTION_REGISTER} on {RESOURCE_KIND}:{id}"
683            ));
684        }
685    }
686    Ok(())
687}
688
689fn register_entry(
690    registry: &ConfigRegistry,
691    auth: &AuthStore,
692    actor: &UserId,
693    ctx: &EvalContext,
694    entry: &ManifestRegistryEntry,
695    now_ms: u128,
696) -> Result<ConfigRegistryEntry, String> {
697    registry
698        .register(
699            auth,
700            actor,
701            ctx,
702            ConfigRegistryDraft {
703                id: entry.id.clone(),
704                resource_type: entry.resource_type.clone(),
705                schema: entry.schema.clone(),
706                mutability: entry.mutability,
707                sensitivity: entry.sensitivity,
708                managed: entry.managed,
709                required_action: entry.required_action.clone(),
710                required_resource: entry.required_resource.clone(),
711                evidence_requirement: entry.evidence,
712            },
713            now_ms,
714        )
715        .map_err(|err| format!("register `{}`: {err}", entry.id))
716}
717
718pub(crate) fn persist_registry_state(
719    runtime: &RedDBRuntime,
720    entries: &[ConfigRegistryEntry],
721) -> Result<(), String> {
722    let json = JsonValue::Array(entries.iter().map(registry_entry_to_json).collect());
723    insert_config_value(
724        runtime,
725        REGISTRY_STATE_KEY,
726        Value::Json(
727            crate::serde_json::to_vec(&json)
728                .map_err(|err| format!("serialize bootstrap registry state: {err}"))?,
729        ),
730    )
731}
732
733fn registry_entry_to_json(entry: &ConfigRegistryEntry) -> JsonValue {
734    let mut obj = JsonMap::new();
735    obj.insert("id".to_string(), JsonValue::String(entry.id.clone()));
736    obj.insert(
737        "version".to_string(),
738        JsonValue::Number(entry.version as f64),
739    );
740    obj.insert(
741        "resource_type".to_string(),
742        JsonValue::String(entry.resource_type.clone()),
743    );
744    obj.insert(
745        "schema".to_string(),
746        JsonValue::String(entry.schema.clone()),
747    );
748    obj.insert(
749        "mutability".to_string(),
750        JsonValue::String(mutability_str(entry.mutability).to_string()),
751    );
752    obj.insert(
753        "sensitivity".to_string(),
754        JsonValue::String(sensitivity_str(entry.sensitivity).to_string()),
755    );
756    obj.insert("managed".to_string(), JsonValue::Bool(entry.managed));
757    obj.insert(
758        "required_action".to_string(),
759        JsonValue::String(entry.required_action.clone()),
760    );
761    obj.insert(
762        "required_resource".to_string(),
763        JsonValue::String(entry.required_resource.clone()),
764    );
765    obj.insert(
766        "evidence".to_string(),
767        JsonValue::String(evidence_str(entry.evidence_requirement).to_string()),
768    );
769    obj.insert(
770        "updated_by".to_string(),
771        JsonValue::String(entry.updated_by.clone()),
772    );
773    obj.insert(
774        "updated_at_ms".to_string(),
775        JsonValue::Number(entry.updated_at_ms as f64),
776    );
777    JsonValue::Object(obj)
778}
779
780fn registry_entry_from_json(value: &JsonValue, idx: usize) -> Result<ConfigRegistryEntry, String> {
781    let obj = object_at(value, "registry_state", idx)?;
782    Ok(ConfigRegistryEntry {
783        id: required_string(obj, "id", "registry_state", idx)?,
784        version: obj
785            .get("version")
786            .and_then(|v| v.as_u64())
787            .ok_or_else(|| format!("registry_state[{idx}].version is required"))?,
788        resource_type: required_string(obj, "resource_type", "registry_state", idx)?,
789        schema: required_string(obj, "schema", "registry_state", idx)?,
790        mutability: parse_mutability(&required_string(obj, "mutability", "registry_state", idx)?)?,
791        sensitivity: parse_sensitivity(&required_string(
792            obj,
793            "sensitivity",
794            "registry_state",
795            idx,
796        )?)?,
797        managed: obj
798            .get("managed")
799            .and_then(|v| v.as_bool())
800            .ok_or_else(|| format!("registry_state[{idx}].managed is required"))?,
801        required_action: required_string(obj, "required_action", "registry_state", idx)?,
802        required_resource: required_string(obj, "required_resource", "registry_state", idx)?,
803        evidence_requirement: parse_evidence(&required_string(
804            obj,
805            "evidence",
806            "registry_state",
807            idx,
808        )?)?,
809        updated_by: required_string(obj, "updated_by", "registry_state", idx)?,
810        updated_at_ms: obj
811            .get("updated_at_ms")
812            .and_then(|v| v.as_u64())
813            .map(u128::from)
814            .ok_or_else(|| format!("registry_state[{idx}].updated_at_ms is required"))?,
815    })
816}
817
818/// Write an initial config value only when the key is absent (issue #1232,
819/// acceptance #3). The fenced bootstrap owner applies the manifest exactly
820/// once, but initial config must use first-boot/write-if-absent semantics so
821/// a value an operator has already set for the same key is never overwritten.
822/// Internal bootstrap bookkeeping (e.g. the registry snapshot) keeps using
823/// [`insert_config_value`] directly, because that state is owned by the
824/// bootstrap path itself, not by the operator.
825fn insert_config_value_if_absent(
826    runtime: &RedDBRuntime,
827    key: &str,
828    value: Value,
829) -> Result<(), String> {
830    if runtime.db().store().get_config(key).is_some() {
831        tracing::info!(
832            key,
833            "bootstrap manifest config key already present; preserving operator value"
834        );
835        return Ok(());
836    }
837    insert_config_value(runtime, key, value)
838}
839
840fn insert_config_value(runtime: &RedDBRuntime, key: &str, value: Value) -> Result<(), String> {
841    let store = runtime.db().store();
842    let _ = store.get_or_create_collection("red_config");
843    let entity = UnifiedEntity::new(
844        EntityId::new(0),
845        EntityKind::TableRow {
846            table: Arc::from("red_config"),
847            row_id: 0,
848        },
849        EntityData::Row(RowData {
850            columns: Vec::new(),
851            named: Some(
852                [
853                    ("key".to_string(), Value::text(key.to_string())),
854                    ("value".to_string(), value),
855                ]
856                .into_iter()
857                .collect::<HashMap<_, _>>(),
858            ),
859            schema: None,
860        }),
861    );
862    store
863        .insert_auto("red_config", entity)
864        .map(|_| ())
865        .map_err(|err| format!("persist config `{key}`: {err}"))
866}
867
868fn registry_context(user: &User) -> EvalContext {
869    EvalContext {
870        principal_tenant: user.tenant_id.clone(),
871        current_tenant: user.tenant_id.clone(),
872        peer_ip: None,
873        mfa_present: false,
874        now_ms: current_unix_ms(),
875        principal_is_admin_role: user.role == Role::Admin,
876        principal_is_platform_scoped: user.tenant_id.is_none(),
877    }
878}
879
880fn manifest_user_context(user: &ManifestUser) -> EvalContext {
881    EvalContext {
882        principal_tenant: user.tenant.clone(),
883        current_tenant: user.tenant.clone(),
884        peer_ip: None,
885        mfa_present: false,
886        now_ms: current_unix_ms(),
887        principal_is_admin_role: user.role == Role::Admin,
888        principal_is_platform_scoped: user.tenant.is_none(),
889    }
890}
891
892fn user_id(tenant: &Option<String>, username: &str) -> UserId {
893    UserId::from_parts(tenant.as_deref(), username)
894}
895
896fn current_unix_ms() -> u128 {
897    std::time::SystemTime::now()
898        .duration_since(std::time::UNIX_EPOCH)
899        .unwrap_or_default()
900        .as_millis()
901}
902
903fn array_field<'a>(
904    obj: &'a JsonMap<String, JsonValue>,
905    name: &str,
906) -> Result<&'a [JsonValue], String> {
907    match obj.get(name) {
908        None => Ok(&[]),
909        Some(JsonValue::Array(values)) => Ok(values.as_slice()),
910        Some(_) => Err(format!(
911            "bootstrap manifest field `{name}` must be an array"
912        )),
913    }
914}
915
916fn object_at<'a>(
917    value: &'a JsonValue,
918    field: &str,
919    idx: usize,
920) -> Result<&'a JsonMap<String, JsonValue>, String> {
921    value
922        .as_object()
923        .ok_or_else(|| format!("{field}[{idx}] must be an object"))
924}
925
926fn required_string(
927    obj: &JsonMap<String, JsonValue>,
928    key: &str,
929    field: &str,
930    idx: usize,
931) -> Result<String, String> {
932    obj.get(key)
933        .and_then(|v| v.as_str())
934        .map(str::trim)
935        .filter(|s| !s.is_empty())
936        .map(str::to_string)
937        .ok_or_else(|| format!("{field}[{idx}].{key} is required"))
938}
939
940fn optional_string(obj: &JsonMap<String, JsonValue>, key: &str) -> Option<String> {
941    obj.get(key)
942        .and_then(|v| v.as_str())
943        .map(str::trim)
944        .filter(|s| !s.is_empty())
945        .map(str::to_string)
946}
947
948fn optional_bool(obj: &JsonMap<String, JsonValue>, key: &str) -> Option<bool> {
949    obj.get(key).and_then(|v| v.as_bool())
950}
951
952fn parse_mutability(value: &str) -> Result<Mutability, String> {
953    match value {
954        "immutable" => Ok(Mutability::Immutable),
955        "mutable_via_governance" => Ok(Mutability::MutableViaGovernance),
956        _ => Err(format!("unknown registry mutability `{value}`")),
957    }
958}
959
960fn mutability_str(value: Mutability) -> &'static str {
961    match value {
962        Mutability::Immutable => "immutable",
963        Mutability::MutableViaGovernance => "mutable_via_governance",
964    }
965}
966
967fn parse_sensitivity(value: &str) -> Result<Sensitivity, String> {
968    match value {
969        "public" => Ok(Sensitivity::Public),
970        "internal" => Ok(Sensitivity::Internal),
971        "confidential" => Ok(Sensitivity::Confidential),
972        "secret" => Ok(Sensitivity::Secret),
973        _ => Err(format!("unknown registry sensitivity `{value}`")),
974    }
975}
976
977fn sensitivity_str(value: Sensitivity) -> &'static str {
978    match value {
979        Sensitivity::Public => "public",
980        Sensitivity::Internal => "internal",
981        Sensitivity::Confidential => "confidential",
982        Sensitivity::Secret => "secret",
983    }
984}
985
986fn parse_evidence(value: &str) -> Result<EvidenceRequirement, String> {
987    match value {
988        "none" => Ok(EvidenceRequirement::None),
989        "metadata" => Ok(EvidenceRequirement::Metadata),
990        "full" => Ok(EvidenceRequirement::Full),
991        _ => Err(format!("unknown registry evidence requirement `{value}`")),
992    }
993}
994
995fn evidence_str(value: EvidenceRequirement) -> &'static str {
996    match value {
997        EvidenceRequirement::None => "none",
998        EvidenceRequirement::Metadata => "metadata",
999        EvidenceRequirement::Full => "full",
1000    }
1001}
1002
1003fn json_to_storage_value(value: &JsonValue) -> Result<Value, String> {
1004    Ok(match value {
1005        JsonValue::Null => Value::Null,
1006        JsonValue::Bool(value) => Value::Boolean(*value),
1007        JsonValue::Integer(value) => Value::Integer(*value),
1008        JsonValue::Number(value) => {
1009            if value.fract().abs() < f64::EPSILON && *value >= 0.0 {
1010                Value::UnsignedInteger(*value as u64)
1011            } else if value.fract().abs() < f64::EPSILON {
1012                Value::Integer(*value as i64)
1013            } else {
1014                Value::Float(*value)
1015            }
1016        }
1017        JsonValue::Decimal(value) => Value::DecimalText(value.clone()),
1018        JsonValue::String(value) => Value::text(value.clone()),
1019        JsonValue::Array(_) | JsonValue::Object(_) => Value::Json(
1020            crate::serde_json::to_vec(value)
1021                .map_err(|err| format!("serialize config JSON value: {err}"))?,
1022        ),
1023    })
1024}
1025
1026fn secret_ref_storage_value(value: &JsonValue, idx: usize) -> Result<Value, String> {
1027    let obj = value
1028        .as_object()
1029        .ok_or_else(|| format!("config[{idx}].secret_ref must be an object"))?;
1030    let collection = required_string(obj, "collection", "config.secret_ref", idx)?;
1031    let key = required_string(obj, "key", "config.secret_ref", idx)?;
1032    let mut out = JsonMap::new();
1033    out.insert(
1034        "type".to_string(),
1035        JsonValue::String("secret_ref".to_string()),
1036    );
1037    out.insert("store".to_string(), JsonValue::String("vault".to_string()));
1038    out.insert("collection".to_string(), JsonValue::String(collection));
1039    out.insert("key".to_string(), JsonValue::String(key));
1040    Ok(Value::Json(
1041        crate::serde_json::to_vec(&JsonValue::Object(out))
1042            .map_err(|err| format!("serialize config[{idx}].secret_ref: {err}"))?,
1043    ))
1044}
1045
1046#[cfg(test)]
1047mod tests {
1048    use super::{apply_manifest_file, BootstrapManifest};
1049    use crate::auth::store::AuthStore;
1050    use crate::auth::AuthConfig;
1051    use crate::storage::schema::Value;
1052    use crate::RedDBRuntime;
1053    use std::sync::atomic::{AtomicU64, Ordering};
1054    use std::sync::Arc;
1055
1056    fn manifest_test_env() -> (RedDBRuntime, Arc<AuthStore>, std::path::PathBuf) {
1057        static COUNTER: AtomicU64 = AtomicU64::new(0);
1058        let id = COUNTER.fetch_add(1, Ordering::Relaxed);
1059        let runtime =
1060            RedDBRuntime::with_options(crate::api::RedDBOptions::in_memory()).expect("runtime");
1061        let auth = Arc::new(AuthStore::new(AuthConfig::default()));
1062        let tmp =
1063            std::env::temp_dir().join(format!("reddb_manifest_{}_{}.json", std::process::id(), id));
1064        (runtime, auth, tmp)
1065    }
1066
1067    fn write_manifest(path: &std::path::Path, body: &str) {
1068        std::fs::write(path, body).expect("write manifest");
1069    }
1070
1071    #[test]
1072    fn bootstrap_intent_manifest_reads_password_files() {
1073        let (_runtime, _auth, path) = manifest_test_env();
1074        let dir = path.parent().expect("manifest parent");
1075        let head_password = dir.join(format!("head-secret-{}", std::process::id()));
1076        let customer_password = dir.join(format!("customer-secret-{}", std::process::id()));
1077        std::fs::write(&head_password, "head-pass\n").expect("write head secret");
1078        std::fs::write(&customer_password, "customer-pass\r\n").expect("write customer secret");
1079
1080        write_manifest(
1081            &path,
1082            &format!(
1083                r#"{{
1084                    "bootstrap": {{
1085                        "preset": "cloud",
1086                        "cloud_head_admin": {{
1087                            "username": "head",
1088                            "password_file": "{}"
1089                        }},
1090                        "customer_admin": {{
1091                            "username": "customer",
1092                            "password_file": "{}"
1093                        }}
1094                    }}
1095                }}"#,
1096                head_password.display(),
1097                customer_password.display()
1098            ),
1099        );
1100
1101        let config = super::bootstrap_config_from_manifest_file(&path)
1102            .expect("parse intent manifest")
1103            .expect("intent present");
1104        assert_eq!(config.preset.as_deref(), Some("cloud"));
1105        assert_eq!(config.cloud_head_admin.as_deref(), Some("head"));
1106        assert_eq!(
1107            config.cloud_head_admin_password.as_deref(),
1108            Some("head-pass")
1109        );
1110        assert_eq!(config.customer_admin.as_deref(), Some("customer"));
1111        assert_eq!(
1112            config.customer_admin_password.as_deref(),
1113            Some("customer-pass")
1114        );
1115        assert!(
1116            config.manifest.is_none(),
1117            "intent manifests must not recurse back into manifest apply"
1118        );
1119
1120        let _ = std::fs::remove_file(&path);
1121        let _ = std::fs::remove_file(&head_password);
1122        let _ = std::fs::remove_file(&customer_password);
1123    }
1124
1125    #[test]
1126    fn bootstrap_intent_manifest_rejects_inline_plaintext_secret() {
1127        let (_runtime, _auth, path) = manifest_test_env();
1128        write_manifest(
1129            &path,
1130            r#"{
1131                "bootstrap": {
1132                    "preset": "production",
1133                    "admin": {
1134                        "username": "ops",
1135                        "password": "inline"
1136                    }
1137                }
1138            }"#,
1139        );
1140
1141        let err = super::bootstrap_config_from_manifest_file(&path)
1142            .expect_err("inline manifest secret must be rejected");
1143        assert!(err.contains("password_file"), "got: {err}");
1144
1145        let _ = std::fs::remove_file(&path);
1146    }
1147
1148    #[test]
1149    fn owner_apply_creates_user_and_initial_config() {
1150        // Acceptance #1/#4: the fenced owner applies the manifest, creating the
1151        // initial admin and writing initial config.
1152        let (runtime, auth, path) = manifest_test_env();
1153        write_manifest(
1154            &path,
1155            r#"{
1156                "users": [{"username":"ops","password":"hunter2","role":"admin"}],
1157                "config": [{"key":"app.feature.x","value":"on"}]
1158            }"#,
1159        );
1160
1161        let actor =
1162            apply_manifest_file(&runtime, &auth, &runtime.config_registry(), &path).expect("apply");
1163        assert_eq!(actor, "ops");
1164        assert!(
1165            auth.get_user(None, "ops").is_some(),
1166            "admin must be created"
1167        );
1168        assert_eq!(
1169            runtime.db().store().get_config("app.feature.x"),
1170            Some(Value::text("on".to_string())),
1171            "initial config must be written on owner apply"
1172        );
1173
1174        let _ = std::fs::remove_file(&path);
1175    }
1176
1177    #[test]
1178    fn owner_apply_reads_user_password_file() {
1179        let (runtime, auth, path) = manifest_test_env();
1180        let password_path = path.with_extension("secret");
1181        std::fs::write(&password_path, "hunter2\n").expect("write secret");
1182        write_manifest(
1183            &path,
1184            &format!(
1185                r#"{{
1186                    "users": [
1187                        {{
1188                            "username": "ops",
1189                            "password_file": "{}",
1190                            "role": "admin"
1191                        }}
1192                    ]
1193                }}"#,
1194                password_path.display()
1195            ),
1196        );
1197
1198        apply_manifest_file(&runtime, &auth, &runtime.config_registry(), &path).expect("apply");
1199        auth.authenticate("ops", "hunter2")
1200            .expect("password must come from mounted file");
1201
1202        let _ = std::fs::remove_file(&path);
1203        let _ = std::fs::remove_file(&password_path);
1204    }
1205
1206    #[test]
1207    fn initial_config_is_write_if_absent_and_keeps_operator_value() {
1208        // Acceptance #3: initial config uses write-if-absent semantics — a value
1209        // an operator has already set for the same key survives manifest apply.
1210        let (runtime, auth, path) = manifest_test_env();
1211        super::insert_config_value(
1212            &runtime,
1213            "app.feature.x",
1214            Value::text("operator".to_string()),
1215        )
1216        .expect("seed operator config");
1217
1218        write_manifest(
1219            &path,
1220            r#"{
1221                "users": [{"username":"ops","password":"hunter2","role":"admin"}],
1222                "config": [{"key":"app.feature.x","value":"manifest"}]
1223            }"#,
1224        );
1225        apply_manifest_file(&runtime, &auth, &runtime.config_registry(), &path).expect("apply");
1226
1227        assert_eq!(
1228            runtime.db().store().get_config("app.feature.x"),
1229            Some(Value::text("operator".to_string())),
1230            "manifest must not overwrite a later operator change"
1231        );
1232
1233        let _ = std::fs::remove_file(&path);
1234    }
1235
1236    #[test]
1237    fn duplicate_manifest_apply_is_rejected_idempotently() {
1238        // Acceptance #4: a duplicate apply (e.g. a restart that re-runs the
1239        // manifest) refuses to recreate existing global auth state instead of
1240        // silently mutating it.
1241        let (runtime, auth, path) = manifest_test_env();
1242        write_manifest(
1243            &path,
1244            r#"{
1245                "users": [{"username":"ops","password":"hunter2","role":"admin"}],
1246                "config": [{"key":"app.feature.x","value":"on"}]
1247            }"#,
1248        );
1249        apply_manifest_file(&runtime, &auth, &runtime.config_registry(), &path).expect("first");
1250
1251        let err = apply_manifest_file(&runtime, &auth, &runtime.config_registry(), &path)
1252            .expect_err("duplicate apply must be rejected");
1253        assert!(err.contains("already exists"), "got: {err}");
1254
1255        let _ = std::fs::remove_file(&path);
1256    }
1257
1258    #[test]
1259    fn cloud_policy_first_bootstrap_protects_cloud_admin_lifecycle() {
1260        // Issue #1233: prove the RedDB Cloud first-boot model on the bootstrap
1261        // manifest path. The cloud/head admin and the customer admin are
1262        // ordinary policy-governed users — broad admin authority is allowed,
1263        // but an explicit deny fences the customer admin out of the protected
1264        // cloud admin lifecycle operations.
1265        use crate::auth::{Role, UserId};
1266
1267        let (runtime, auth, path) = manifest_test_env();
1268        write_manifest(
1269            &path,
1270            r#"{
1271                "actor": "cloud-admin",
1272                "users": [
1273                    {"username":"cloud-admin","password":"head-secret","role":"admin"},
1274                    {"username":"customer-admin","password":"tenant-secret","role":"admin"}
1275                ],
1276                "policies": [
1277                    {
1278                        "id": "customer-admin-allow-all",
1279                        "version": 1,
1280                        "statements": [
1281                            {"effect":"allow","actions":["*"],"resources":["*"]}
1282                        ]
1283                    },
1284                    {
1285                        "id": "cloud-admin-protection",
1286                        "version": 1,
1287                        "statements": [
1288                            {
1289                                "effect":"deny",
1290                                "actions":[
1291                                    "user:delete",
1292                                    "user:disable",
1293                                    "user:password:change",
1294                                    "user:role:update"
1295                                ],
1296                                "resources":["user:cloud-admin"]
1297                            }
1298                        ]
1299                    }
1300                ],
1301                "attachments": [
1302                    {"user":"customer-admin","policy":"customer-admin-allow-all"},
1303                    {"user":"customer-admin","policy":"cloud-admin-protection"}
1304                ]
1305            }"#,
1306        );
1307
1308        let actor =
1309            apply_manifest_file(&runtime, &auth, &runtime.config_registry(), &path).expect("apply");
1310        assert_eq!(actor, "cloud-admin");
1311
1312        // Acceptance #1: both admins exist as ordinary, policy-governed users.
1313        assert!(
1314            auth.get_user(None, "cloud-admin").is_some(),
1315            "cloud/head admin must be created"
1316        );
1317        assert!(
1318            auth.get_user(None, "customer-admin").is_some(),
1319            "customer admin must be created"
1320        );
1321
1322        let customer = UserId::platform("customer-admin");
1323        let cloud_admin = UserId::platform("cloud-admin");
1324
1325        // Acceptance #3: the customer admin is denied the protected lifecycle
1326        // operations on the cloud admin resource.
1327        for action in [
1328            "user:delete",
1329            "user:disable",
1330            "user:password:change",
1331            "user:role:update",
1332        ] {
1333            assert!(
1334                !auth.check_user_lifecycle_authz(&customer, Role::Admin, action, &cloud_admin),
1335                "customer admin must be denied {action} on the cloud admin"
1336            );
1337        }
1338
1339        // Acceptance #2: the customer admin keeps broad admin authority outside
1340        // the protected cloud admin lifecycle — the same operations succeed
1341        // against any other user.
1342        let other = UserId::platform("some-tenant-user");
1343        for action in [
1344            "user:delete",
1345            "user:disable",
1346            "user:password:change",
1347            "user:role:update",
1348        ] {
1349            assert!(
1350                auth.check_user_lifecycle_authz(&customer, Role::Admin, action, &other),
1351                "customer admin must retain {action} on non-protected users"
1352            );
1353        }
1354
1355        let _ = std::fs::remove_file(&path);
1356    }
1357
1358    #[test]
1359    fn rejects_policy_condition_system_owned() {
1360        // Acceptance #4: policies in a manifest still reject the retired
1361        // `condition.system_owned` key — protection is expressed with explicit
1362        // resource-scoped deny policies, not a magic system-owned flag.
1363        let result = BootstrapManifest::parse(
1364            r#"{
1365                "users": [{"username":"cloud-admin","password":"head","role":"admin"}],
1366                "policies": [{
1367                    "id": "p-sys",
1368                    "version": 1,
1369                    "statements": [{
1370                        "effect": "allow",
1371                        "actions": ["admin:reload"],
1372                        "resources": ["*"],
1373                        "condition": { "system_owned": true }
1374                    }]
1375                }]
1376            }"#,
1377        );
1378
1379        match result {
1380            Ok(_) => panic!("manifest accepted policy condition.system_owned"),
1381            Err(err) => assert!(err.contains("system_owned"), "got: {err}"),
1382        }
1383    }
1384
1385    #[test]
1386    fn rejects_system_owned_user_field() {
1387        let result = BootstrapManifest::parse(
1388            r#"{
1389                "users": [
1390                    {
1391                        "username": "ops",
1392                        "password": "hunter2",
1393                        "role": "admin",
1394                        "system_owned": true
1395                    }
1396                ]
1397            }"#,
1398        );
1399
1400        match result {
1401            Ok(_) => panic!("manifest accepted users[0].system_owned"),
1402            Err(err) => assert!(err.contains("users[0].system_owned"), "got: {err}"),
1403        }
1404    }
1405}