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: "/srv/api/shine.workspace.toml".into(),
890 workspace_contents: "version = 1\n[env]\nfiles = [\"env/development.toml\"]\n".into(),
891 mode: "development".into(),
892 override_process_env: false,
893 sources: vec![SourceSnapshot {
894 path: "env/development.toml".into(),
895 contents: "version = 1\n[plain]\nPUBLIC = \"x\"\n[secret]\nAPI_TOKEN = true\nNPM_TOKEN = true\n[payload]\ndata = \"ciphertext\"\n".into(),
896 }],
897 }
898 }
899
900 #[test]
901 fn allow_separates_declared_keys_from_release_subset() {
902 let snapshot = snapshot();
903 let allow = allow_from_snapshot(
904 &snapshot,
905 &["API_TOKEN".into()],
906 &["bun".into(), "test".into()],
907 )
908 .unwrap();
909
910 assert_eq!(allow.release, ["API_TOKEN"]);
911 assert_eq!(
912 allow.sources[0].declared_secrets,
913 ["API_TOKEN", "NPM_TOKEN"]
914 );
915 }
916
917 #[test]
918 fn all_declared_release_expands_to_a_stable_explicit_list() {
919 let snapshot = snapshot();
920 let release = resolve_release(&snapshot, &[], true).unwrap();
921 assert_eq!(release, ["API_TOKEN", "NPM_TOKEN"]);
922
923 assert!(resolve_release(&snapshot, &["API_TOKEN".into()], true).is_err());
924 assert!(resolve_release(&snapshot, &[], false).is_err());
925 }
926
927 #[test]
928 fn trusted_remote_update_replaces_one_allow_and_preserves_policy_identity() {
929 let original = snapshot();
930 let allow = allow_from_snapshot(
931 &original,
932 &["API_TOKEN".into()],
933 &["bun".into(), "test".into()],
934 )
935 .unwrap();
936 let store = PolicyStore {
937 version: POLICY_VERSION,
938 policies: vec![BrokerPolicy {
939 name: "dev-api".into(),
940 ssh_target: "dev".into(),
941 project: "friendly-api-name".into(),
942 workspace_sha256: sha256(original.workspace_contents.as_bytes()),
943 remote_workspace: Some(original.workspace_path.clone()),
944 allow: vec![allow],
945 }],
946 };
947 let mut changed = original.clone();
948 changed
949 .workspace_contents
950 .push_str("# new workspace revision\n");
951 changed.sources[0].contents = changed.sources[0]
952 .contents
953 .replace("[payload]", "NEW_TOKEN = true\n[payload]");
954 let release = resolve_release(&changed, &[], true).unwrap();
955
956 let plan = plan_remote_enrollment(
957 &store,
958 "dev",
959 &changed,
960 &release,
961 &["bun".into(), "test".into()],
962 Some("dev-api"),
963 )
964 .unwrap();
965
966 assert!(plan.previous.is_some());
967 assert_eq!(plan.candidate.project, "friendly-api-name");
968 assert_eq!(
969 plan.candidate.remote_workspace.as_deref(),
970 Some("/srv/api/shine.workspace.toml")
971 );
972 assert_eq!(
973 plan.candidate.allow[0].release,
974 ["API_TOKEN", "NEW_TOKEN", "NPM_TOKEN"]
975 );
976 assert!(plan.diff().unwrap().unwrap().contains("NEW_TOKEN"));
977 assert!(
978 plan_remote_enrollment(
979 &store,
980 "other-host",
981 &changed,
982 &release,
983 &["bun".into(), "test".into()],
984 Some("dev-api"),
985 )
986 .is_err()
987 );
988 }
989
990 #[tokio::test]
991 async fn trusted_remote_update_applies_once_and_rejects_a_stale_preview() {
992 let dir = crate::test_support::make_temp_dir("shine-broker-remote-update").await;
993 let config = Config::new_for_test(&dir);
994 let original = snapshot();
995 let allow = allow_from_snapshot(
996 &original,
997 &["API_TOKEN".into()],
998 &["bun".into(), "test".into()],
999 )
1000 .unwrap();
1001 let store = PolicyStore {
1002 version: POLICY_VERSION,
1003 policies: vec![BrokerPolicy {
1004 name: "dev-api".into(),
1005 ssh_target: "dev".into(),
1006 project: "api".into(),
1007 workspace_sha256: sha256(original.workspace_contents.as_bytes()),
1008 remote_workspace: None,
1009 allow: vec![allow],
1010 }],
1011 };
1012 save_store(&config, &store).await.unwrap();
1013
1014 let mut changed = original;
1015 changed.sources[0].contents = changed.sources[0]
1016 .contents
1017 .replace("[payload]", "NEW_TOKEN = true\n[payload]");
1018 let release = resolve_release(&changed, &[], true).unwrap();
1019 let plan = plan_remote_enrollment(
1020 &store,
1021 "dev",
1022 &changed,
1023 &release,
1024 &["bun".into(), "test".into()],
1025 Some("dev-api"),
1026 )
1027 .unwrap();
1028
1029 apply_remote_enrollment(&config, &plan).await.unwrap();
1030 let loaded = load_store(&config).await.unwrap();
1031 assert_eq!(loaded.policies[0], plan.candidate);
1032 assert!(apply_remote_enrollment(&config, &plan).await.is_err());
1033 tokio::fs::remove_dir_all(dir).await.unwrap();
1034 }
1035
1036 #[test]
1037 fn exact_policy_match_rejects_changed_argv_or_source() {
1038 let snapshot = snapshot();
1039 let allow = allow_from_snapshot(
1040 &snapshot,
1041 &["API_TOKEN".into()],
1042 &["bun".into(), "test".into()],
1043 )
1044 .unwrap();
1045 let store = PolicyStore {
1046 version: POLICY_VERSION,
1047 policies: vec![BrokerPolicy {
1048 name: "dev-api".into(),
1049 ssh_target: "dev".into(),
1050 project: "api".into(),
1051 workspace_sha256: sha256(snapshot.workspace_contents.as_bytes()),
1052 remote_workspace: None,
1053 allow: vec![allow],
1054 }],
1055 };
1056
1057 let matched =
1058 match_workspace_request(&store, "dev", &snapshot, &["bun".into(), "test".into()])
1059 .unwrap();
1060 assert_eq!(matched.release, ["API_TOKEN"]);
1061
1062 assert!(
1063 match_workspace_request(
1064 &store,
1065 "dev",
1066 &snapshot,
1067 &["bun".into(), "run".into(), "test".into()],
1068 )
1069 .is_err()
1070 );
1071 let mut changed = snapshot.clone();
1072 changed.sources[0].contents.push_str("# changed\n");
1073 assert!(
1074 match_workspace_request(&store, "dev", &changed, &["bun".into(), "test".into()],)
1075 .is_err()
1076 );
1077 }
1078
1079 #[test]
1080 fn wire_display_fields_reject_control_characters_and_limits() {
1081 assert!(validate_wire_string("safe", "field").is_ok());
1082 assert!(validate_wire_string("evil\u{1b}[2J", "field").is_err());
1083 assert!(validate_wire_string(&"x".repeat(4097), "field").is_err());
1084 assert!(validate_wire_strings(&vec!["x".into(); 129], "argv").is_err());
1085 }
1086
1087 #[tokio::test]
1088 async fn policy_store_is_written_with_private_permissions() {
1089 let dir = crate::test_support::make_temp_dir("shine-broker-policy").await;
1090 let config = Config::new_for_test(&dir);
1091 let snapshot = snapshot();
1092 let allow = allow_from_snapshot(
1093 &snapshot,
1094 &["API_TOKEN".into()],
1095 &["bun".into(), "test".into()],
1096 )
1097 .unwrap();
1098 let store = PolicyStore {
1099 version: POLICY_VERSION,
1100 policies: vec![BrokerPolicy {
1101 name: "dev-api".into(),
1102 ssh_target: "dev".into(),
1103 project: "api".into(),
1104 workspace_sha256: sha256(snapshot.workspace_contents.as_bytes()),
1105 remote_workspace: None,
1106 allow: vec![allow],
1107 }],
1108 };
1109 save_store(&config, &store).await.unwrap();
1110 let loaded = load_store(&config).await.unwrap();
1111 assert_eq!(loaded, store);
1112 #[cfg(unix)]
1113 {
1114 use std::os::unix::fs::PermissionsExt;
1115 let mode = tokio::fs::metadata(policy_path(&config))
1116 .await
1117 .unwrap()
1118 .permissions()
1119 .mode();
1120 assert_eq!(mode & 0o777, 0o600);
1121 }
1122 tokio::fs::remove_dir_all(&dir).await.unwrap();
1123 }
1124
1125 #[tokio::test]
1126 async fn additional_local_policy_files_are_merged_and_validated() {
1127 let dir = crate::test_support::make_temp_dir("shine-broker-policy-merge").await;
1128 let config = Config::new_for_test(&dir);
1129 let snapshot = snapshot();
1130 let allow = allow_from_snapshot(
1131 &snapshot,
1132 &["API_TOKEN".into()],
1133 &["bun".into(), "test".into()],
1134 )
1135 .unwrap();
1136 let extra = PolicyStore {
1137 version: POLICY_VERSION,
1138 policies: vec![BrokerPolicy {
1139 name: "extra-api".into(),
1140 ssh_target: "dev".into(),
1141 project: "api".into(),
1142 workspace_sha256: sha256(snapshot.workspace_contents.as_bytes()),
1143 remote_workspace: None,
1144 allow: vec![allow],
1145 }],
1146 };
1147 let path = dir.join("extra.toml");
1148 tokio::fs::write(&path, toml::to_string(&extra).unwrap())
1149 .await
1150 .unwrap();
1151 #[cfg(unix)]
1152 {
1153 use std::os::unix::fs::PermissionsExt;
1154 tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
1155 .await
1156 .unwrap();
1157 }
1158
1159 let merged = load_stores(&config, std::slice::from_ref(&path))
1160 .await
1161 .unwrap();
1162 assert_eq!(merged.policies.len(), 1);
1163 assert_eq!(merged.policies[0].name, "extra-api");
1164 tokio::fs::remove_dir_all(&dir).await.unwrap();
1165 }
1166
1167 #[cfg(unix)]
1168 #[tokio::test]
1169 async fn policy_store_rejects_symlink_and_broad_permissions() {
1170 use std::os::unix::fs::{PermissionsExt, symlink};
1171 let dir = crate::test_support::make_temp_dir("shine-broker-policy-safety").await;
1172 let config = Config::new_for_test(&dir);
1173 let path = policy_path(&config);
1174 tokio::fs::create_dir_all(path.parent().unwrap())
1175 .await
1176 .unwrap();
1177 let target = dir.join("real-policy.toml");
1178 tokio::fs::write(&target, "version = 1\n").await.unwrap();
1179 symlink(&target, &path).unwrap();
1180 assert!(
1181 load_store(&config)
1182 .await
1183 .unwrap_err()
1184 .to_string()
1185 .contains("symlink")
1186 );
1187 tokio::fs::remove_file(&path).await.unwrap();
1188 tokio::fs::write(&path, "version = 1\n").await.unwrap();
1189 tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
1190 .await
1191 .unwrap();
1192 assert!(
1193 load_store(&config)
1194 .await
1195 .unwrap_err()
1196 .to_string()
1197 .contains("too broad")
1198 );
1199 tokio::fs::remove_dir_all(&dir).await.unwrap();
1200 }
1201}