1use serde::{Deserialize, Serialize};
2
3use crate::boundary::{github_name, identifier, reference};
4use crate::secret_boundary::reject_secrets;
5use crate::{CONFIG_SCHEMA, ConnectorError};
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(deny_unknown_fields)]
9pub struct ConnectorConfig {
10 pub schema: String,
11 pub config_id: String,
12 pub connection_ref: String,
13 pub organization: String,
14 pub repositories: Vec<String>,
15 #[serde(default)]
16 pub allowed_actions: Vec<String>,
17}
18
19impl ConnectorConfig {
20 pub fn validate(&self) -> Result<(), ConnectorError> {
21 if self.schema != CONFIG_SCHEMA {
22 return Err(ConnectorError::new(
23 "schema",
24 "expected zixcel://github/connector-config/v1",
25 ));
26 }
27 identifier("config_id", &self.config_id)?;
28 reference("connection_ref", &self.connection_ref)?;
29 github_name("organization", &self.organization)?;
30 if self.repositories.is_empty() || self.repositories.len() > 256 {
31 return Err(ConnectorError::new(
32 "repositories",
33 "must contain 1..=256 repositories",
34 ));
35 }
36 for repository in &self.repositories {
37 github_name("repositories", repository)?;
38 }
39 const WRITES: &[&str] = &[
40 "create-private-repository",
41 "push-repository-snapshot",
42 "delete-repository",
43 "create-issue",
44 "create-pull-request",
45 "dispatch-workflow",
46 ];
47 if self
48 .allowed_actions
49 .iter()
50 .any(|action| !WRITES.contains(&action.as_str()))
51 || self.allowed_actions.len() > WRITES.len()
52 {
53 return Err(ConnectorError::new(
54 "allowed_actions",
55 "unknown or excessive write capabilities",
56 ));
57 }
58 Ok(())
59 }
60
61 pub fn authorize_request(
63 &self,
64 request: &crate::GitHubApiRequest,
65 ) -> Result<(), ConnectorError> {
66 self.validate()?;
67 crate::validate_api_request(request)?;
68 if self.connection_ref != request.connection_ref
69 || !self.organization.eq_ignore_ascii_case(&request.owner)
70 || !self
71 .repositories
72 .iter()
73 .any(|name| name.eq_ignore_ascii_case(&request.repository))
74 {
75 return Err(ConnectorError::new(
76 "target",
77 "request is outside the configured connection and repositories",
78 ));
79 }
80 if !request.action.is_read_only()
81 && !self
82 .allowed_actions
83 .iter()
84 .any(|action| action == request.action.kind())
85 {
86 return Err(ConnectorError::new(
87 "action",
88 "write action is not enabled by local configuration",
89 ));
90 }
91 Ok(())
92 }
93
94 pub(crate) fn normalized(&self) -> Self {
95 let mut value = self.clone();
96 value.allowed_actions.sort();
97 value.allowed_actions.dedup();
98 value.repositories.sort_by_key(|item| item.to_lowercase());
99 value
100 .repositories
101 .dedup_by(|left, right| left.eq_ignore_ascii_case(right));
102 value
103 }
104}
105
106pub fn parse_config(source: &str) -> Result<ConnectorConfig, ConnectorError> {
107 reject_secrets(source)?;
108 let config: ConnectorConfig = toml::from_str(source).map_err(|_| {
109 ConnectorError::new(
110 "config",
111 "invalid TOML or fields do not match the GitHub v1 schema",
112 )
113 })?;
114 config.validate()?;
115 Ok(config)
116}