Skip to main content

liminal_server/auth_membership/
mapping.rs

1use super::{
2    BTreeMap, BTreeSet, CONTRACT_VERSION, LysSession, MembershipConfig, MembershipRefusal,
3    PageBounds, Resource, ServerError, Subject, invalid, malformed,
4};
5
6/// The verified subject a membership is asked about: the holder the one
7/// `Connect` verification proved, with the directory kind its signed pass
8/// states. Only [`Self::from_session`] makes one.
9#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
10pub struct MembershipSubject {
11    pub(super) id: String,
12    pub(super) kind: String,
13}
14
15impl MembershipSubject {
16    /// The subject an admitted Lys session proved.
17    ///
18    /// # Errors
19    /// [`MembershipRefusal::HolderKindUnknown`] when the session cannot name
20    /// its holder's kind; [`MembershipRefusal::HolderMalformed`] when the id or
21    /// kind is empty or carries control characters.
22    pub fn from_session(session: &dyn LysSession) -> Result<Self, MembershipRefusal> {
23        let kind = session
24            .holder_kind()
25            .ok_or(MembershipRefusal::HolderKindUnknown)?;
26        let id = session.holder();
27        if malformed(id) || malformed(kind) {
28            return Err(MembershipRefusal::HolderMalformed);
29        }
30        Ok(Self {
31            id: id.to_owned(),
32            kind: kind.to_owned(),
33        })
34    }
35
36    /// The directory kind, as the signed pass states it.
37    #[must_use]
38    pub fn kind(&self) -> &str {
39        &self.kind
40    }
41
42    /// The contract's subject.
43    #[must_use]
44    pub fn to_contract(&self) -> Subject {
45        Subject {
46            id: self.id.clone(),
47            kind: self.kind.clone(),
48        }
49    }
50
51    pub(super) fn is(&self, subject: &Subject) -> bool {
52        self.id == subject.id && self.kind == subject.kind
53    }
54}
55
56impl std::fmt::Debug for MembershipSubject {
57    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        formatter
59            .debug_struct("MembershipSubject")
60            .field("id", &crate::auth_pass::ByteLength(self.id.len()))
61            .field("kind", &self.kind)
62            .finish()
63    }
64}
65
66/// A protected channel's placement as the product mapping states it.
67#[derive(Clone, Debug, PartialEq, Eq)]
68pub struct MappedChannel {
69    pub(super) workspace: Resource,
70    pub(super) resource: Resource,
71}
72
73impl MappedChannel {
74    /// The workspace the channel is placed under.
75    #[must_use]
76    pub const fn workspace(&self) -> &Resource {
77        &self.workspace
78    }
79
80    /// The placed channel resource.
81    #[must_use]
82    pub const fn resource(&self) -> &Resource {
83        &self.resource
84    }
85}
86
87/// The validated product mapping.
88#[derive(Debug)]
89pub(super) struct ChannelMap {
90    pub(super) channels: BTreeMap<String, MappedChannel>,
91    pub(super) conversations: BTreeMap<u64, String>,
92    pub(super) by_resource: BTreeMap<String, String>,
93    pub(super) workspaces: BTreeSet<String>,
94}
95
96pub(super) fn channel_map(config: &MembershipConfig) -> Result<ChannelMap, ServerError> {
97    if config.channels.is_empty() {
98        return Err(invalid(
99            "auth.lys.membership.channels: at least one protected channel is required",
100        ));
101    }
102    let mut channels = BTreeMap::new();
103    let mut by_resource = BTreeMap::new();
104    let mut workspaces = BTreeSet::new();
105    for entry in &config.channels {
106        for (member, value) in [
107            ("channel", entry.channel.as_str()),
108            ("workspace", entry.workspace.as_str()),
109            ("resource", entry.resource.as_str()),
110        ] {
111            if malformed(value) {
112                return Err(invalid(format!(
113                    "auth.lys.membership.channels: a {member} is empty or contains control characters"
114                )));
115            }
116        }
117        let mapped = MappedChannel {
118            workspace: Resource {
119                kind: config.workspace_kind.clone(),
120                id: entry.workspace.clone(),
121            },
122            resource: Resource {
123                kind: config.channel_kind.clone(),
124                id: entry.resource.clone(),
125            },
126        };
127        if channels.insert(entry.channel.clone(), mapped).is_some() {
128            return Err(invalid(
129                "auth.lys.membership.channels: a channel is listed twice",
130            ));
131        }
132        if by_resource
133            .insert(entry.resource.clone(), entry.channel.clone())
134            .is_some()
135        {
136            return Err(invalid(
137                "auth.lys.membership.channels: a placed resource is mapped to two channels",
138            ));
139        }
140        workspaces.insert(entry.workspace.clone());
141    }
142    let mut conversations = BTreeMap::new();
143    for entry in &config.conversations {
144        if !channels.contains_key(&entry.channel) {
145            return Err(invalid(
146                "auth.lys.membership.conversations: a conversation names an unlisted channel",
147            ));
148        }
149        if conversations
150            .insert(entry.conversation, entry.channel.clone())
151            .is_some()
152        {
153            return Err(invalid(
154                "auth.lys.membership.conversations: a conversation is listed twice",
155            ));
156        }
157    }
158    Ok(ChannelMap {
159        channels,
160        conversations,
161        by_resource,
162        workspaces,
163    })
164}
165
166pub(super) fn validated_bounds(config: &MembershipConfig) -> Result<PageBounds, ServerError> {
167    if config.contract != CONTRACT_VERSION {
168        return Err(invalid(format!(
169            "auth.lys.membership.contract {} is not served; this liminal speaks {CONTRACT_VERSION}",
170            config.contract
171        )));
172    }
173    for (member, value) in [
174        ("log_identity", config.log_identity.as_str()),
175        ("workspace_kind", config.workspace_kind.as_str()),
176        ("channel_kind", config.channel_kind.as_str()),
177    ] {
178        if malformed(value) {
179            return Err(invalid(format!(
180                "auth.lys.membership.{member} is empty or contains control characters"
181            )));
182        }
183    }
184    if config.workspace_kind == config.channel_kind {
185        return Err(invalid(
186            "auth.lys.membership: workspace_kind and channel_kind must differ",
187        ));
188    }
189    if config.page_rows == 0 || config.page_bytes == 0 || config.max_decisions == 0 {
190        return Err(invalid(
191            "auth.lys.membership: page_rows, page_bytes and max_decisions must each be positive",
192        ));
193    }
194    Ok(PageBounds {
195        rows: config.page_rows,
196        bytes: config.page_bytes,
197    })
198}