liminal_server/auth_membership/
mapping.rs1use super::{
2 BTreeMap, BTreeSet, CONTRACT_VERSION, LysSession, MembershipConfig, MembershipRefusal,
3 PageBounds, Resource, ServerError, Subject, invalid, malformed,
4};
5
6#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
10pub struct MembershipSubject {
11 pub(super) id: String,
12 pub(super) kind: String,
13}
14
15impl MembershipSubject {
16 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 #[must_use]
38 pub fn kind(&self) -> &str {
39 &self.kind
40 }
41
42 #[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#[derive(Clone, Debug, PartialEq, Eq)]
68pub struct MappedChannel {
69 pub(super) workspace: Resource,
70 pub(super) resource: Resource,
71}
72
73impl MappedChannel {
74 #[must_use]
76 pub const fn workspace(&self) -> &Resource {
77 &self.workspace
78 }
79
80 #[must_use]
82 pub const fn resource(&self) -> &Resource {
83 &self.resource
84 }
85}
86
87#[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}