1use 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(®istry_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 ®istry_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, ®istered)?;
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
818fn 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 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 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 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 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 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 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 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 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}