Skip to main content

origin_connector/
descriptor.rs

1use origin_domain::{ConnectorId, ProductPermission};
2use serde::{Deserialize, Serialize};
3
4/// How a connector authenticates.
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
7#[serde(rename_all = "snake_case")]
8pub enum AuthKind {
9    /// Authorization code flow with PKCE (ADR-0015).
10    OAuth2,
11    /// A token the user pastes in. Still stored in the credential store.
12    PersonalAccessToken,
13    /// Public data only.
14    None,
15}
16
17/// What a connector is.
18///
19/// Kept separate from the trait so it can be rendered in a settings UI, serialised into
20/// the app manifest, and reviewed without instantiating anything.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
23pub struct ConnectorDescriptor {
24    pub id: ConnectorId,
25    pub display_name: String,
26    pub auth: AuthKind,
27
28    /// The rights this connector needs at the external service.
29    ///
30    /// Declared, not inferred: a reviewer can see in one place whether an integration
31    /// asks for write access, and a product can refuse to ship one that does.
32    pub required_permissions: Vec<ProductPermission>,
33
34    /// Whether the user may connect several accounts (ADR-0016).
35    pub supports_multiple_accounts: bool,
36}
37
38impl ConnectorDescriptor {
39    pub fn new(id: ConnectorId, display_name: impl Into<String>, auth: AuthKind) -> Self {
40        Self {
41            id,
42            display_name: display_name.into(),
43            auth,
44            required_permissions: Vec::new(),
45            supports_multiple_accounts: true,
46        }
47    }
48
49    pub fn with_permissions(
50        mut self,
51        permissions: impl IntoIterator<Item = ProductPermission>,
52    ) -> Self {
53        self.required_permissions = permissions.into_iter().collect();
54        self
55    }
56
57    pub fn single_account(mut self) -> Self {
58        self.supports_multiple_accounts = false;
59        self
60    }
61
62    /// Whether this connector asks for any write access.
63    ///
64    /// Products that want to stay read-only assert on this in a test.
65    pub fn requests_write_access(&self) -> bool {
66        self.required_permissions
67            .iter()
68            .any(ProductPermission::is_write)
69    }
70}
71
72/// Who a set of credentials belongs to.
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
75pub struct AccountIdentity {
76    /// The service's own identifier — a GitHub login, a GA4 property id.
77    pub external_id: String,
78    /// What to show the user.
79    pub display_name: String,
80    /// Scopes the service reports as actually granted, which can be fewer than
81    /// requested. Surfacing this is how a product explains a missing feature.
82    pub granted_scopes: Vec<String>,
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn a_read_only_connector_declares_no_write_permissions() {
91        let descriptor =
92            ConnectorDescriptor::new(ConnectorId::new("analytics"), "Analytics", AuthKind::OAuth2)
93                .with_permissions([ProductPermission::read("analytics.reports")]);
94
95        assert!(!descriptor.requests_write_access());
96    }
97
98    #[test]
99    fn write_access_is_visible_in_the_descriptor() {
100        let descriptor =
101            ConnectorDescriptor::new(ConnectorId::new("github"), "GitHub", AuthKind::OAuth2)
102                .with_permissions([
103                    ProductPermission::read("notifications"),
104                    ProductPermission::write("projects"),
105                ]);
106
107        assert!(descriptor.requests_write_access());
108    }
109}