1use super::workspace;
4use crate::config::Config;
5use anyhow::{Context, Result, bail};
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8use std::collections::{BTreeMap, BTreeSet};
9use std::path::{Path, PathBuf};
10
11pub const POLICY_VERSION: u32 = 1;
12const POLICY_FILE: &str = "ssh-secret-broker.toml";
13const RESERVED_REMOTE_ENV: &[&str] = &[
14 "SHINE_SSH_SESSION",
15 "SHINE_SSH_TOKEN",
16 "SHINE_SSH_REMOTE_SOCK",
17 "SHINE_TERMINAL_THEME",
18];
19
20#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
21pub struct WorkspaceSnapshot {
22 pub workspace_path: String,
23 pub workspace_contents: String,
24 pub mode: String,
25 pub override_process_env: bool,
26 pub sources: Vec<SourceSnapshot>,
27}
28
29#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
30pub struct SourceSnapshot {
31 pub path: String,
33 pub contents: String,
34}
35
36#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
37pub struct PolicyStore {
38 #[serde(default = "policy_version")]
39 pub version: u32,
40 #[serde(default, rename = "policy")]
41 pub policies: Vec<BrokerPolicy>,
42}
43
44impl Default for PolicyStore {
45 fn default() -> Self {
46 Self {
47 version: POLICY_VERSION,
48 policies: Vec::new(),
49 }
50 }
51}
52
53#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
54pub struct BrokerPolicy {
55 pub name: String,
56 pub ssh_target: String,
57 #[serde(default, skip_serializing_if = "String::is_empty")]
58 pub project: String,
59 pub workspace_sha256: String,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub remote_workspace: Option<String>,
62 #[serde(default)]
63 pub allow: Vec<BrokerAllow>,
64}
65
66#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
67pub struct BrokerAllow {
68 pub mode: String,
69 pub argv: Vec<String>,
70 pub release: Vec<String>,
71 pub sources: Vec<BrokerSource>,
72}
73
74#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
75pub struct BrokerSource {
76 pub path: String,
77 pub sha256: String,
78 #[serde(default)]
79 pub declared_secrets: Vec<String>,
80}
81
82#[derive(Clone, Debug)]
83pub struct MatchedPolicy {
84 pub policy_name: String,
85 pub project: String,
86 pub release: Vec<String>,
87}
88
89fn policy_version() -> u32 {
90 POLICY_VERSION
91}
92
93pub fn policy_path(config: &Config) -> PathBuf {
94 config.shine_dir().join(POLICY_FILE)
95}
96
97pub fn sha256(bytes: &[u8]) -> String {
98 format!("{:x}", Sha256::digest(bytes))
99}
100
101pub async fn load_store(config: &Config) -> Result<PolicyStore> {
102 load_store_from(&policy_path(config)).await
103}
104
105pub async fn load_stores(config: &Config, overrides: &[PathBuf]) -> Result<PolicyStore> {
106 let mut merged = load_store(config).await?;
107 for path in overrides {
108 let mut extra = load_store_from(path).await?;
109 merged.policies.append(&mut extra.policies);
110 }
111 validate_store(&merged)?;
112 Ok(merged)
113}
114
115pub async fn load_store_from(path: &Path) -> Result<PolicyStore> {
116 validate_policy_file(path, true).await?;
117 let contents = match tokio::fs::read_to_string(path).await {
118 Ok(contents) => contents,
119 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
120 return Ok(PolicyStore {
121 version: POLICY_VERSION,
122 policies: Vec::new(),
123 });
124 }
125 Err(error) => return Err(error).with_context(|| format!("reading {}", path.display())),
126 };
127 let store: PolicyStore =
128 toml::from_str(&contents).with_context(|| format!("parsing {}", path.display()))?;
129 validate_store(&store)?;
130 Ok(store)
131}
132
133async fn save_store(config: &Config, store: &PolicyStore) -> Result<()> {
134 validate_store(store)?;
135 let path = policy_path(config);
136 validate_policy_file(&path, true).await?;
137 let contents = toml::to_string_pretty(store).context("serializing SSH secret broker policy")?;
138 secure_atomic_write(&path, contents.as_bytes()).await?;
139 validate_policy_file(&path, false).await
140}
141
142async fn secure_atomic_write(path: &Path, contents: &[u8]) -> Result<()> {
143 use tokio::io::AsyncWriteExt;
144 let parent = path.parent().unwrap_or_else(|| Path::new("."));
145 tokio::fs::create_dir_all(parent).await?;
146 let temp = parent.join(format!(".shine-broker-write-{}", uuid::Uuid::new_v4()));
147 #[cfg(unix)]
148 let std_file = {
149 use std::os::unix::fs::OpenOptionsExt;
150 std::fs::OpenOptions::new()
151 .write(true)
152 .create_new(true)
153 .mode(0o600)
154 .open(&temp)
155 .with_context(|| format!("creating {}", temp.display()))?
156 };
157 #[cfg(not(unix))]
158 let std_file = std::fs::OpenOptions::new()
159 .write(true)
160 .create_new(true)
161 .open(&temp)
162 .with_context(|| format!("creating {}", temp.display()))?;
163 let mut file = tokio::fs::File::from_std(std_file);
164 if let Err(error) = async {
165 file.write_all(contents).await?;
166 file.sync_all().await
167 }
168 .await
169 {
170 let _ = tokio::fs::remove_file(&temp).await;
171 return Err(error).with_context(|| format!("writing {}", temp.display()));
172 }
173 drop(file);
174 crate::persist::finalize_temp(&temp, path).await
175}
176
177async fn validate_policy_file(path: &Path, allow_missing: bool) -> Result<()> {
178 let metadata = match tokio::fs::symlink_metadata(path).await {
179 Ok(metadata) => metadata,
180 Err(error) if allow_missing && error.kind() == std::io::ErrorKind::NotFound => {
181 return Ok(());
182 }
183 Err(error) => return Err(error).with_context(|| format!("inspecting {}", path.display())),
184 };
185 if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
186 bail!(
187 "SSH secret broker policy must be a regular file, not a symlink: {}",
188 path.display()
189 );
190 }
191 #[cfg(unix)]
192 {
193 use std::os::unix::fs::MetadataExt;
194 let expected_uid = unsafe { libc::geteuid() };
195 if metadata.uid() != expected_uid {
196 bail!(
197 "SSH secret broker policy is not owned by the current user: {}",
198 path.display()
199 );
200 }
201 if metadata.mode() & 0o077 != 0 {
202 bail!(
203 "SSH secret broker policy permissions are too broad (expected 0600): {}",
204 path.display()
205 );
206 }
207 }
208 Ok(())
209}
210
211fn validate_store(store: &PolicyStore) -> Result<()> {
212 if store.version != POLICY_VERSION {
213 bail!(
214 "unsupported SSH secret broker policy version {}",
215 store.version
216 );
217 }
218 let mut names = BTreeSet::new();
219 let mut selectors = BTreeSet::new();
220 for policy in &store.policies {
221 validate_name(&policy.name, "policy name")?;
222 if !names.insert(policy.name.clone()) {
223 bail!("duplicate SSH secret broker policy name: {}", policy.name);
224 }
225 if policy.ssh_target.trim().is_empty() {
226 bail!("policy {} has an empty ssh_target", policy.name);
227 }
228 validate_wire_string(&policy.ssh_target, "ssh target")?;
229 if let Some(remote_workspace) = &policy.remote_workspace {
230 validate_wire_string(remote_workspace, "remote workspace")?;
231 if !Path::new(remote_workspace).is_absolute() {
232 bail!(
233 "policy {} remote_workspace must be an absolute path",
234 policy.name
235 );
236 }
237 }
238 validate_digest(&policy.workspace_sha256)?;
239 if policy.allow.is_empty() {
240 bail!(
241 "policy {} must contain at least one allow entry",
242 policy.name
243 );
244 }
245 for allow in &policy.allow {
246 workspace::validate_broker_mode(&allow.mode)?;
247 if allow.argv.is_empty() {
248 bail!("policy {} contains an empty argv", policy.name);
249 }
250 validate_wire_strings(&allow.argv, "argv")?;
251 validate_release(&allow.release)?;
252 if allow.sources.is_empty() {
253 bail!(
254 "policy {} contains an allow entry with no sources",
255 policy.name
256 );
257 }
258 let mut source_paths = BTreeSet::new();
259 for source in &allow.sources {
260 if source.path.is_empty() || !source_paths.insert(source.path.clone()) {
261 bail!(
262 "policy {} contains an empty or duplicate source path",
263 policy.name
264 );
265 }
266 validate_wire_string(&source.path, "source path")?;
267 validate_digest(&source.sha256)?;
268 validate_release(&source.declared_secrets)?;
269 }
270 if !allow.release.iter().all(|key| {
271 allow
272 .sources
273 .iter()
274 .any(|item| item.declared_secrets.contains(key))
275 }) {
276 bail!(
277 "policy {} releases a key not declared by its sources",
278 policy.name
279 );
280 }
281 let selector = format!(
282 "{}\0{}\0{}\0{:?}\0{:?}",
283 policy.ssh_target, policy.workspace_sha256, allow.mode, allow.argv, allow.sources
284 );
285 if !selectors.insert(selector) {
286 bail!("multiple policy entries have the same exact request selector");
287 }
288 }
289 }
290 Ok(())
291}
292
293fn validate_name(value: &str, what: &str) -> Result<()> {
294 if value.is_empty()
295 || !value
296 .chars()
297 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
298 {
299 bail!("{what} must contain only letters, digits, dots, hyphens, and underscores");
300 }
301 Ok(())
302}
303
304fn validate_digest(value: &str) -> Result<()> {
305 if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
306 bail!("SHA-256 digest must be 64 hexadecimal characters");
307 }
308 Ok(())
309}
310
311fn validate_release(values: &[String]) -> Result<()> {
312 let mut unique = BTreeSet::new();
313 for value in values {
314 super::validate_env_key(value)?;
315 if RESERVED_REMOTE_ENV.contains(&value.as_str()) {
316 bail!("cannot release into shine-managed SSH variable {value}");
317 }
318 if !unique.insert(value) {
319 bail!("duplicate secret key: {value}");
320 }
321 }
322 Ok(())
323}
324
325pub fn validate_wire_strings(values: &[String], what: &str) -> Result<()> {
326 if values.len() > 128 {
327 bail!("{what} contains too many values");
328 }
329 for value in values {
330 validate_wire_string(value, what)?;
331 }
332 Ok(())
333}
334
335pub fn validate_wire_string(value: &str, what: &str) -> Result<()> {
336 if value.len() > 4096 {
337 bail!("{what} exceeds the 4096-byte limit");
338 }
339 if value
340 .chars()
341 .any(|ch| ch == '\0' || (ch.is_control() && !matches!(ch, '\t')))
342 {
343 bail!("{what} contains a disallowed control character");
344 }
345 Ok(())
346}
347
348#[allow(clippy::too_many_arguments)] pub async fn policy_from_workspace(
350 name: &str,
351 ssh_target: &str,
352 project: &str,
353 workspace_path: &Path,
354 remote_workspace: Option<&str>,
355 mode: &str,
356 release: &[String],
357 release_all_declared: bool,
358 argv: &[String],
359) -> Result<BrokerPolicy> {
360 validate_name(name, "policy name")?;
361 if let Some(remote_workspace) = remote_workspace {
362 validate_wire_string(remote_workspace, "remote workspace")?;
363 if !Path::new(remote_workspace).is_absolute() {
364 bail!("remote workspace must be an absolute path");
365 }
366 }
367 let snapshot = workspace::snapshot_for_broker(Some(workspace_path), mode).await?;
368 let release = resolve_release(&snapshot, release, release_all_declared)?;
369 let allow = allow_from_snapshot(&snapshot, &release, argv)?;
370 Ok(BrokerPolicy {
371 name: name.to_string(),
372 ssh_target: ssh_target.to_string(),
373 project: project.to_string(),
374 workspace_sha256: sha256(snapshot.workspace_contents.as_bytes()),
375 remote_workspace: remote_workspace.map(str::to_string),
376 allow: vec![allow],
377 })
378}
379
380pub fn allow_from_snapshot(
381 snapshot: &WorkspaceSnapshot,
382 release: &[String],
383 argv: &[String],
384) -> Result<BrokerAllow> {
385 validate_release(release)?;
386 validate_wire_strings(argv, "argv")?;
387 let mut sources = Vec::with_capacity(snapshot.sources.len());
388 let mut all_declared = BTreeSet::new();
389 for source in &snapshot.sources {
390 let declared_secrets =
391 workspace::declared_secrets_from_source(&source.path, &source.contents)?;
392 all_declared.extend(declared_secrets.iter().cloned());
393 sources.push(BrokerSource {
394 path: source.path.clone(),
395 sha256: sha256(source.contents.as_bytes()),
396 declared_secrets,
397 });
398 }
399 for key in release {
400 if !all_declared.contains(key) {
401 bail!("release key {key} is not declared by the selected workspace sources");
402 }
403 }
404 Ok(BrokerAllow {
405 mode: snapshot.mode.clone(),
406 argv: argv.to_vec(),
407 release: release.to_vec(),
408 sources,
409 })
410}
411
412pub fn resolve_release(
413 snapshot: &WorkspaceSnapshot,
414 requested: &[String],
415 release_all_declared: bool,
416) -> Result<Vec<String>> {
417 if release_all_declared {
418 if !requested.is_empty() {
419 bail!("--release and --release-all-declared are mutually exclusive");
420 }
421 let mut declared = BTreeSet::new();
422 for source in &snapshot.sources {
423 declared.extend(workspace::declared_secrets_from_source(
424 &source.path,
425 &source.contents,
426 )?);
427 }
428 if declared.is_empty() {
429 bail!("--release-all-declared found no declared workspace secrets");
430 }
431 return Ok(declared.into_iter().collect());
432 }
433 if requested.is_empty() {
434 bail!("pass at least one --release KEY or --release-all-declared");
435 }
436 validate_release(requested)?;
437 Ok(requested.to_vec())
438}
439
440pub fn match_workspace_request(
441 store: &PolicyStore,
442 ssh_target: &str,
443 snapshot: &WorkspaceSnapshot,
444 argv: &[String],
445) -> Result<MatchedPolicy> {
446 validate_snapshot(snapshot)?;
447 validate_wire_strings(argv, "argv")?;
448 let workspace_digest = sha256(snapshot.workspace_contents.as_bytes());
449 let actual_sources = snapshot
450 .sources
451 .iter()
452 .map(|source| {
453 Ok(BrokerSource {
454 path: source.path.clone(),
455 sha256: sha256(source.contents.as_bytes()),
456 declared_secrets: workspace::declared_secrets_from_source(
457 &source.path,
458 &source.contents,
459 )?,
460 })
461 })
462 .collect::<Result<Vec<_>>>()?;
463
464 let mut matches = Vec::new();
465 for policy in &store.policies {
466 if policy.ssh_target != ssh_target || policy.workspace_sha256 != workspace_digest {
467 continue;
468 }
469 if let Some(expected) = &policy.remote_workspace
470 && expected != &snapshot.workspace_path
471 {
472 continue;
473 }
474 for allow in &policy.allow {
475 if allow.mode == snapshot.mode && allow.argv == argv && allow.sources == actual_sources
476 {
477 matches.push(MatchedPolicy {
478 policy_name: policy.name.clone(),
479 project: policy.project.clone(),
480 release: allow.release.clone(),
481 });
482 }
483 }
484 }
485 match matches.len() {
486 1 => Ok(matches.remove(0)),
487 0 => bail!("no SSH secret broker policy matches this workspace request"),
488 _ => bail!("multiple SSH secret broker policies match this workspace request"),
489 }
490}
491
492pub fn validate_snapshot(snapshot: &WorkspaceSnapshot) -> Result<()> {
493 validate_wire_string(&snapshot.workspace_path, "workspace path")?;
494 validate_wire_string(&snapshot.mode, "mode")?;
495 if snapshot.workspace_contents.len() > 256 * 1024 {
496 bail!("workspace contents exceed the 256 KiB limit");
497 }
498 if snapshot.sources.len() > 64 {
499 bail!("workspace request contains too many sources");
500 }
501 let total = snapshot.sources.iter().try_fold(0usize, |sum, source| {
502 validate_wire_string(&source.path, "source path")?;
503 sum.checked_add(source.contents.len())
504 .context("source size overflow")
505 })?;
506 if total > 768 * 1024 {
507 bail!("workspace source contents exceed the 768 KiB limit");
508 }
509 Ok(())
510}
511
512#[allow(clippy::too_many_arguments)] pub async fn handle_policy_add(
514 config: &Config,
515 name: &str,
516 ssh_target: &str,
517 project: &str,
518 workspace_path: &Path,
519 remote_workspace: Option<&str>,
520 mode: &str,
521 release: &[String],
522 release_all_declared: bool,
523 argv: &[String],
524) -> Result<()> {
525 let policy = policy_from_workspace(
526 name,
527 ssh_target,
528 project,
529 workspace_path,
530 remote_workspace,
531 mode,
532 release,
533 release_all_declared,
534 argv,
535 )
536 .await?;
537 let mut store = load_store(config).await?;
538 if store.policies.iter().any(|item| item.name == name) {
539 bail!("policy {name} already exists; use `shine env broker policy update {name}`");
540 }
541 store.policies.push(policy);
542 save_store(config, &store).await?;
543 println!("added SSH secret broker policy {name}");
544 Ok(())
545}
546
547#[allow(clippy::too_many_arguments)] pub async fn handle_policy_update(
549 config: &Config,
550 name: &str,
551 ssh_target: &str,
552 project: &str,
553 workspace_path: &Path,
554 remote_workspace: Option<&str>,
555 mode: &str,
556 release: &[String],
557 release_all_declared: bool,
558 argv: &[String],
559) -> Result<()> {
560 let policy = policy_from_workspace(
561 name,
562 ssh_target,
563 project,
564 workspace_path,
565 remote_workspace,
566 mode,
567 release,
568 release_all_declared,
569 argv,
570 )
571 .await?;
572 let mut store = load_store(config).await?;
573 let existing = store
574 .policies
575 .iter_mut()
576 .find(|item| item.name == name)
577 .with_context(|| format!("policy {name} does not exist"))?;
578 let old = toml::to_string_pretty(&*existing)?;
579 let new = toml::to_string_pretty(&policy)?;
580 if old == new {
581 println!("policy {name} is current");
582 return Ok(());
583 }
584 println!(
585 "{}",
586 similar::TextDiff::from_lines(&old, &new).unified_diff()
587 );
588 let confirmed = dialoguer::Confirm::new()
589 .with_prompt(format!("Replace SSH secret broker policy {name}?"))
590 .default(false)
591 .interact()
592 .context("reading policy update confirmation")?;
593 if !confirmed {
594 bail!("policy update cancelled");
595 }
596 *existing = policy;
597 save_store(config, &store).await?;
598 println!("updated SSH secret broker policy {name}");
599 Ok(())
600}
601
602pub async fn handle_policy_list(config: &Config) -> Result<()> {
603 let store = load_store(config).await?;
604 if store.policies.is_empty() {
605 println!("No SSH secret broker policies configured.");
606 }
607 for policy in store.policies {
608 println!(
609 "{}: {} ({})",
610 policy.name, policy.ssh_target, policy.project
611 );
612 }
613 Ok(())
614}
615
616pub async fn handle_policy_info(config: &Config, name: &str) -> Result<()> {
617 let store = load_store(config).await?;
618 let policy = store
619 .policies
620 .iter()
621 .find(|item| item.name == name)
622 .with_context(|| format!("policy {name} does not exist"))?;
623 print!("{}", toml::to_string_pretty(policy)?);
624 Ok(())
625}
626
627pub async fn handle_policy_remove(config: &Config, name: &str) -> Result<()> {
628 let mut store = load_store(config).await?;
629 let before = store.policies.len();
630 store.policies.retain(|item| item.name != name);
631 if store.policies.len() == before {
632 bail!("policy {name} does not exist");
633 }
634 save_store(config, &store).await?;
635 println!("removed SSH secret broker policy {name}");
636 Ok(())
637}
638
639pub async fn handle_policy_diff(
640 config: &Config,
641 name: &str,
642 workspace_path: &Path,
643 mode: &str,
644 release: &[String],
645 release_all_declared: bool,
646 argv: &[String],
647) -> Result<()> {
648 let store = load_store(config).await?;
649 let existing = store
650 .policies
651 .iter()
652 .find(|item| item.name == name)
653 .with_context(|| format!("policy {name} does not exist"))?;
654 let candidate = policy_from_workspace(
655 name,
656 &existing.ssh_target,
657 &existing.project,
658 workspace_path,
659 existing.remote_workspace.as_deref(),
660 mode,
661 release,
662 release_all_declared,
663 argv,
664 )
665 .await?;
666 let old = toml::to_string_pretty(existing)?;
667 let new = toml::to_string_pretty(&candidate)?;
668 if old == new {
669 println!("policy {name} is current");
670 } else {
671 println!(
672 "{}",
673 similar::TextDiff::from_lines(&old, &new).unified_diff()
674 );
675 }
676 Ok(())
677}
678
679pub async fn handle_describe(
680 workspace_path: Option<&Path>,
681 mode: &str,
682 release: &[String],
683 release_all_declared: bool,
684 argv: &[String],
685) -> Result<()> {
686 let snapshot = workspace::snapshot_for_broker(workspace_path, mode).await?;
687 let release = resolve_release(&snapshot, release, release_all_declared)?;
688 let allow = allow_from_snapshot(&snapshot, &release, argv)?;
689 if crate::ssh::broker_session_available() {
690 let summary = crate::ssh::describe_broker_workspace(snapshot, &release, argv).await?;
691 println!("{summary}");
692 return Ok(());
693 }
694 print_description(&snapshot, &allow);
695 Ok(())
696}
697
698fn print_description(snapshot: &WorkspaceSnapshot, allow: &BrokerAllow) {
699 println!(
700 "workspace_sha256 = \"{}\"",
701 sha256(snapshot.workspace_contents.as_bytes())
702 );
703 println!("mode = {:?}", allow.mode);
704 println!("argv = {:?}", allow.argv);
705 println!("release = {:?}", allow.release);
706 for source in &allow.sources {
707 println!(
708 "source {} {} {:?}",
709 source.path, source.sha256, source.declared_secrets
710 );
711 }
712}
713
714#[derive(Clone, Debug)]
715pub struct RemoteEnrollmentPlan {
716 pub name: String,
717 pub candidate: BrokerPolicy,
718 pub previous: Option<BrokerPolicy>,
719}
720
721impl RemoteEnrollmentPlan {
722 pub fn action_label(&self) -> String {
723 if self.previous.is_some() {
724 format!("update local policy {}", self.name)
725 } else {
726 format!("create local policy {}", self.name)
727 }
728 }
729
730 pub fn diff(&self) -> Result<Option<String>> {
731 let Some(previous) = &self.previous else {
732 return Ok(None);
733 };
734 let old = toml::to_string_pretty(previous)?;
735 let new = toml::to_string_pretty(&self.candidate)?;
736 Ok(Some(
737 similar::TextDiff::from_lines(&old, &new)
738 .unified_diff()
739 .to_string(),
740 ))
741 }
742}
743
744pub fn plan_remote_enrollment(
745 store: &PolicyStore,
746 ssh_target: &str,
747 snapshot: &WorkspaceSnapshot,
748 release: &[String],
749 argv: &[String],
750 update_policy: Option<&str>,
751) -> Result<RemoteEnrollmentPlan> {
752 let allow = allow_from_snapshot(snapshot, release, argv)?;
753 if let Some(name) = update_policy {
754 validate_name(name, "policy name")?;
755 let existing = store
756 .policies
757 .iter()
758 .find(|policy| policy.name == name)
759 .with_context(|| format!("policy {name} does not exist"))?;
760 if existing.ssh_target != ssh_target {
761 bail!(
762 "policy {name} targets {}, not the current SSH target {ssh_target}",
763 existing.ssh_target
764 );
765 }
766 if let Some(expected) = &existing.remote_workspace
767 && expected != &snapshot.workspace_path
768 {
769 bail!(
770 "policy {name} requires remote workspace {expected}, but the request came from {}",
771 snapshot.workspace_path
772 );
773 }
774 let matching = existing
775 .allow
776 .iter()
777 .enumerate()
778 .filter(|(_, item)| item.mode == snapshot.mode && item.argv == argv)
779 .map(|(index, _)| index)
780 .collect::<Vec<_>>();
781 let [index] = matching.as_slice() else {
782 bail!(
783 "policy {name} must contain exactly one allow entry with mode {} and the requested argv before it can be updated from remote metadata",
784 snapshot.mode
785 );
786 };
787 let mut candidate = existing.clone();
788 candidate.workspace_sha256 = sha256(snapshot.workspace_contents.as_bytes());
789 candidate.allow[*index] = allow;
790 let mut proposed = store.clone();
791 let target = proposed
792 .policies
793 .iter_mut()
794 .find(|policy| policy.name == name)
795 .expect("existing policy was cloned");
796 *target = candidate.clone();
797 validate_store(&proposed)?;
798 return Ok(RemoteEnrollmentPlan {
799 name: name.to_string(),
800 candidate,
801 previous: Some(existing.clone()),
802 });
803 }
804
805 let project = Path::new(&snapshot.workspace_path)
806 .parent()
807 .and_then(Path::file_name)
808 .and_then(|value| value.to_str())
809 .unwrap_or("project");
810 let program = argv.first().map(String::as_str).unwrap_or("command");
811 let raw_name = format!("{ssh_target}-{project}-{}-{program}", snapshot.mode);
812 let name = raw_name
813 .chars()
814 .map(|ch| {
815 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
816 ch
817 } else {
818 '-'
819 }
820 })
821 .collect::<String>();
822 validate_name(&name, "generated policy name")?;
823 let policy = BrokerPolicy {
824 name: name.clone(),
825 ssh_target: ssh_target.to_string(),
826 project: project.to_string(),
827 workspace_sha256: sha256(snapshot.workspace_contents.as_bytes()),
828 remote_workspace: None,
829 allow: vec![allow],
830 };
831 if store.policies.iter().any(|item| item.name == name) {
832 bail!("policy {name} already exists; inspect or update it explicitly");
833 }
834 let mut proposed = store.clone();
835 proposed.policies.push(policy.clone());
836 validate_store(&proposed)?;
837 Ok(RemoteEnrollmentPlan {
838 name,
839 candidate: policy,
840 previous: None,
841 })
842}
843
844pub async fn apply_remote_enrollment(
845 config: &Config,
846 plan: &RemoteEnrollmentPlan,
847) -> Result<String> {
848 let mut store = load_store(config).await?;
849 if let Some(previous) = &plan.previous {
850 let current = store
851 .policies
852 .iter_mut()
853 .find(|policy| policy.name == plan.name)
854 .with_context(|| format!("policy {} no longer exists", plan.name))?;
855 if current != previous {
856 bail!(
857 "policy {} changed after the enrollment preview; inspect and retry",
858 plan.name
859 );
860 }
861 *current = plan.candidate.clone();
862 } else {
863 if store.policies.iter().any(|item| item.name == plan.name) {
864 bail!(
865 "policy {} was created after the enrollment preview; inspect it explicitly",
866 plan.name
867 );
868 }
869 store.policies.push(plan.candidate.clone());
870 }
871 save_store(config, &store).await?;
872 Ok(plan.name.clone())
873}
874
875pub fn decrypt_workspace_snapshot<'a>(
876 config: &'a Config,
877 snapshot: &'a WorkspaceSnapshot,
878 release: &'a [String],
879) -> impl std::future::Future<Output = Result<BTreeMap<String, String>>> + 'a {
880 workspace::decrypt_broker_snapshot(config, snapshot, release)
881}
882
883#[cfg(test)]
884mod tests {
885 use super::*;
886
887 fn snapshot() -> WorkspaceSnapshot {
888 WorkspaceSnapshot {
889 workspace_path: std::env::temp_dir()
890 .join("srv/api/shine.workspace.toml")
891 .to_string_lossy()
892 .into_owned(),
893 workspace_contents: "version = 1\n[env]\nfiles = [\"env/development.toml\"]\n".into(),
894 mode: "development".into(),
895 override_process_env: false,
896 sources: vec![SourceSnapshot {
897 path: "env/development.toml".into(),
898 contents: "version = 1\n[plain]\nPUBLIC = \"x\"\n[secret]\nAPI_TOKEN = true\nNPM_TOKEN = true\n[payload]\ndata = \"ciphertext\"\n".into(),
899 }],
900 }
901 }
902
903 #[test]
904 fn allow_separates_declared_keys_from_release_subset() {
905 let snapshot = snapshot();
906 let allow = allow_from_snapshot(
907 &snapshot,
908 &["API_TOKEN".into()],
909 &["bun".into(), "test".into()],
910 )
911 .unwrap();
912
913 assert_eq!(allow.release, ["API_TOKEN"]);
914 assert_eq!(
915 allow.sources[0].declared_secrets,
916 ["API_TOKEN", "NPM_TOKEN"]
917 );
918 }
919
920 #[test]
921 fn all_declared_release_expands_to_a_stable_explicit_list() {
922 let snapshot = snapshot();
923 let release = resolve_release(&snapshot, &[], true).unwrap();
924 assert_eq!(release, ["API_TOKEN", "NPM_TOKEN"]);
925
926 assert!(resolve_release(&snapshot, &["API_TOKEN".into()], true).is_err());
927 assert!(resolve_release(&snapshot, &[], false).is_err());
928 }
929
930 #[test]
931 fn trusted_remote_update_replaces_one_allow_and_preserves_policy_identity() {
932 let original = snapshot();
933 let allow = allow_from_snapshot(
934 &original,
935 &["API_TOKEN".into()],
936 &["bun".into(), "test".into()],
937 )
938 .unwrap();
939 let store = PolicyStore {
940 version: POLICY_VERSION,
941 policies: vec![BrokerPolicy {
942 name: "dev-api".into(),
943 ssh_target: "dev".into(),
944 project: "friendly-api-name".into(),
945 workspace_sha256: sha256(original.workspace_contents.as_bytes()),
946 remote_workspace: Some(original.workspace_path.clone()),
947 allow: vec![allow],
948 }],
949 };
950 let mut changed = original.clone();
951 changed
952 .workspace_contents
953 .push_str("# new workspace revision\n");
954 changed.sources[0].contents = changed.sources[0]
955 .contents
956 .replace("[payload]", "NEW_TOKEN = true\n[payload]");
957 let release = resolve_release(&changed, &[], true).unwrap();
958
959 let plan = plan_remote_enrollment(
960 &store,
961 "dev",
962 &changed,
963 &release,
964 &["bun".into(), "test".into()],
965 Some("dev-api"),
966 )
967 .unwrap();
968
969 assert!(plan.previous.is_some());
970 assert_eq!(plan.candidate.project, "friendly-api-name");
971 assert_eq!(
972 plan.candidate.remote_workspace.as_deref(),
973 Some(original.workspace_path.as_str())
974 );
975 assert_eq!(
976 plan.candidate.allow[0].release,
977 ["API_TOKEN", "NEW_TOKEN", "NPM_TOKEN"]
978 );
979 assert!(plan.diff().unwrap().unwrap().contains("NEW_TOKEN"));
980 assert!(
981 plan_remote_enrollment(
982 &store,
983 "other-host",
984 &changed,
985 &release,
986 &["bun".into(), "test".into()],
987 Some("dev-api"),
988 )
989 .is_err()
990 );
991 }
992
993 #[tokio::test]
994 async fn trusted_remote_update_applies_once_and_rejects_a_stale_preview() {
995 let dir = crate::test_support::make_temp_dir("shine-broker-remote-update").await;
996 let config = Config::new_for_test(&dir);
997 let original = snapshot();
998 let allow = allow_from_snapshot(
999 &original,
1000 &["API_TOKEN".into()],
1001 &["bun".into(), "test".into()],
1002 )
1003 .unwrap();
1004 let store = PolicyStore {
1005 version: POLICY_VERSION,
1006 policies: vec![BrokerPolicy {
1007 name: "dev-api".into(),
1008 ssh_target: "dev".into(),
1009 project: "api".into(),
1010 workspace_sha256: sha256(original.workspace_contents.as_bytes()),
1011 remote_workspace: None,
1012 allow: vec![allow],
1013 }],
1014 };
1015 save_store(&config, &store).await.unwrap();
1016
1017 let mut changed = original;
1018 changed.sources[0].contents = changed.sources[0]
1019 .contents
1020 .replace("[payload]", "NEW_TOKEN = true\n[payload]");
1021 let release = resolve_release(&changed, &[], true).unwrap();
1022 let plan = plan_remote_enrollment(
1023 &store,
1024 "dev",
1025 &changed,
1026 &release,
1027 &["bun".into(), "test".into()],
1028 Some("dev-api"),
1029 )
1030 .unwrap();
1031
1032 apply_remote_enrollment(&config, &plan).await.unwrap();
1033 let loaded = load_store(&config).await.unwrap();
1034 assert_eq!(loaded.policies[0], plan.candidate);
1035 assert!(apply_remote_enrollment(&config, &plan).await.is_err());
1036 tokio::fs::remove_dir_all(dir).await.unwrap();
1037 }
1038
1039 #[test]
1040 fn exact_policy_match_rejects_changed_argv_or_source() {
1041 let snapshot = snapshot();
1042 let allow = allow_from_snapshot(
1043 &snapshot,
1044 &["API_TOKEN".into()],
1045 &["bun".into(), "test".into()],
1046 )
1047 .unwrap();
1048 let store = PolicyStore {
1049 version: POLICY_VERSION,
1050 policies: vec![BrokerPolicy {
1051 name: "dev-api".into(),
1052 ssh_target: "dev".into(),
1053 project: "api".into(),
1054 workspace_sha256: sha256(snapshot.workspace_contents.as_bytes()),
1055 remote_workspace: None,
1056 allow: vec![allow],
1057 }],
1058 };
1059
1060 let matched =
1061 match_workspace_request(&store, "dev", &snapshot, &["bun".into(), "test".into()])
1062 .unwrap();
1063 assert_eq!(matched.release, ["API_TOKEN"]);
1064
1065 assert!(
1066 match_workspace_request(
1067 &store,
1068 "dev",
1069 &snapshot,
1070 &["bun".into(), "run".into(), "test".into()],
1071 )
1072 .is_err()
1073 );
1074 let mut changed = snapshot.clone();
1075 changed.sources[0].contents.push_str("# changed\n");
1076 assert!(
1077 match_workspace_request(&store, "dev", &changed, &["bun".into(), "test".into()],)
1078 .is_err()
1079 );
1080 }
1081
1082 #[test]
1083 fn wire_display_fields_reject_control_characters_and_limits() {
1084 assert!(validate_wire_string("safe", "field").is_ok());
1085 assert!(validate_wire_string("evil\u{1b}[2J", "field").is_err());
1086 assert!(validate_wire_string(&"x".repeat(4097), "field").is_err());
1087 assert!(validate_wire_strings(&vec!["x".into(); 129], "argv").is_err());
1088 }
1089
1090 #[tokio::test]
1091 async fn policy_store_is_written_with_private_permissions() {
1092 let dir = crate::test_support::make_temp_dir("shine-broker-policy").await;
1093 let config = Config::new_for_test(&dir);
1094 let snapshot = snapshot();
1095 let allow = allow_from_snapshot(
1096 &snapshot,
1097 &["API_TOKEN".into()],
1098 &["bun".into(), "test".into()],
1099 )
1100 .unwrap();
1101 let store = PolicyStore {
1102 version: POLICY_VERSION,
1103 policies: vec![BrokerPolicy {
1104 name: "dev-api".into(),
1105 ssh_target: "dev".into(),
1106 project: "api".into(),
1107 workspace_sha256: sha256(snapshot.workspace_contents.as_bytes()),
1108 remote_workspace: None,
1109 allow: vec![allow],
1110 }],
1111 };
1112 save_store(&config, &store).await.unwrap();
1113 let loaded = load_store(&config).await.unwrap();
1114 assert_eq!(loaded, store);
1115 #[cfg(unix)]
1116 {
1117 use std::os::unix::fs::PermissionsExt;
1118 let mode = tokio::fs::metadata(policy_path(&config))
1119 .await
1120 .unwrap()
1121 .permissions()
1122 .mode();
1123 assert_eq!(mode & 0o777, 0o600);
1124 }
1125 tokio::fs::remove_dir_all(&dir).await.unwrap();
1126 }
1127
1128 #[tokio::test]
1129 async fn additional_local_policy_files_are_merged_and_validated() {
1130 let dir = crate::test_support::make_temp_dir("shine-broker-policy-merge").await;
1131 let config = Config::new_for_test(&dir);
1132 let snapshot = snapshot();
1133 let allow = allow_from_snapshot(
1134 &snapshot,
1135 &["API_TOKEN".into()],
1136 &["bun".into(), "test".into()],
1137 )
1138 .unwrap();
1139 let extra = PolicyStore {
1140 version: POLICY_VERSION,
1141 policies: vec![BrokerPolicy {
1142 name: "extra-api".into(),
1143 ssh_target: "dev".into(),
1144 project: "api".into(),
1145 workspace_sha256: sha256(snapshot.workspace_contents.as_bytes()),
1146 remote_workspace: None,
1147 allow: vec![allow],
1148 }],
1149 };
1150 let path = dir.join("extra.toml");
1151 tokio::fs::write(&path, toml::to_string(&extra).unwrap())
1152 .await
1153 .unwrap();
1154 #[cfg(unix)]
1155 {
1156 use std::os::unix::fs::PermissionsExt;
1157 tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
1158 .await
1159 .unwrap();
1160 }
1161
1162 let merged = load_stores(&config, std::slice::from_ref(&path))
1163 .await
1164 .unwrap();
1165 assert_eq!(merged.policies.len(), 1);
1166 assert_eq!(merged.policies[0].name, "extra-api");
1167 tokio::fs::remove_dir_all(&dir).await.unwrap();
1168 }
1169
1170 #[cfg(unix)]
1171 #[tokio::test]
1172 async fn policy_store_rejects_symlink_and_broad_permissions() {
1173 use std::os::unix::fs::{PermissionsExt, symlink};
1174 let dir = crate::test_support::make_temp_dir("shine-broker-policy-safety").await;
1175 let config = Config::new_for_test(&dir);
1176 let path = policy_path(&config);
1177 tokio::fs::create_dir_all(path.parent().unwrap())
1178 .await
1179 .unwrap();
1180 let target = dir.join("real-policy.toml");
1181 tokio::fs::write(&target, "version = 1\n").await.unwrap();
1182 symlink(&target, &path).unwrap();
1183 assert!(
1184 load_store(&config)
1185 .await
1186 .unwrap_err()
1187 .to_string()
1188 .contains("symlink")
1189 );
1190 tokio::fs::remove_file(&path).await.unwrap();
1191 tokio::fs::write(&path, "version = 1\n").await.unwrap();
1192 tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
1193 .await
1194 .unwrap();
1195 assert!(
1196 load_store(&config)
1197 .await
1198 .unwrap_err()
1199 .to_string()
1200 .contains("too broad")
1201 );
1202 tokio::fs::remove_dir_all(&dir).await.unwrap();
1203 }
1204}