1use std::sync::Arc;
2
3use http::StatusCode;
4use sofie_core::{Error, Result};
5use sofie_db::{Db, Resolve};
6
7use crate::answer::{self, Response};
8use crate::caller::Caller;
9use crate::routes::Route;
10use crate::session::RequestSession;
11use crate::target::Target;
12use crate::{Access, Area, AreaMembers, RequestContext};
13
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub struct Permission(&'static str);
17
18impl Permission {
19 #[must_use]
20 pub const fn new(name: &'static str) -> Self {
21 Self(name)
22 }
23
24 #[must_use]
25 pub const fn name(self) -> &'static str {
26 self.0
27 }
28}
29
30#[must_use]
31pub fn is_permission(text: &str) -> bool {
32 let axis = |part: &str| {
33 part == "*"
34 || (part.starts_with(|first: char| first.is_ascii_lowercase())
35 && part.chars().all(|character| {
36 character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_'
37 }))
38 };
39 text.split('.').count() == 4 && text.split('.').all(axis)
40}
41
42fn covers(grant: &str, needed: &str) -> bool {
44 let mut needed_axes = needed.split('.');
45 let covered = grant.split('.').all(|axis| {
46 needed_axes
47 .next()
48 .is_some_and(|needed| axis == "*" || axis == needed)
49 });
50 covered && needed_axes.next().is_none()
51}
52
53pub struct RoutePermission {
54 pub method: &'static str,
55 pub path: &'static str,
56 pub permission: Permission,
57}
58
59#[derive(Clone, Default, Hash)]
61pub struct Permissions {
62 granted: Arc<[String]>,
63 #[cfg(feature = "tokens")]
65 scopes: Option<Arc<[String]>>,
66}
67
68impl Permissions {
69 #[must_use]
70 pub fn can(&self, permission: Permission) -> bool {
71 self.can_named(permission.name())
72 }
73
74 #[must_use]
75 pub fn can_named(&self, permission: &str) -> bool {
76 let granted = self.holds(permission);
77 #[cfg(all(feature = "toolbar", debug_assertions))]
78 record_check(permission, granted);
79 granted
80 }
81
82 fn holds(&self, permission: &str) -> bool {
84 let granted = self.granted.iter().any(|grant| covers(grant, permission));
85 #[cfg(feature = "tokens")]
86 let granted = granted
87 && self
88 .scopes
89 .as_ref()
90 .is_none_or(|scopes| scopes.iter().any(|scope| covers(scope, permission)));
91 granted
92 }
93}
94
95#[cfg(all(feature = "toolbar", debug_assertions))]
97fn record_check(permission: &str, granted: bool) {
98 let outcome = if granted { "granted" } else { "refused" };
99 drop(tracing::info_span!("permission", otel.name = permission, permission = outcome).entered());
100}
101
102impl<A: Area, M> RequestContext<A, M> {
103 #[must_use]
105 pub fn permissions(&self) -> &Permissions {
106 &self.matched.gates.permissions
107 }
108}
109
110impl<A: Area, M> Resolve<RequestContext<A, M>> for Permissions {
111 fn resolve(context: &mut RequestContext<A, M>) -> impl Future<Output = Result<Self>> + Send {
112 std::future::ready(Ok(context.permissions().clone()))
113 }
114}
115
116#[derive(Default)]
117pub(crate) struct Permits {
118 members: Option<Arc<dyn AreaMembers>>,
119 table: &'static [RoutePermission],
120}
121
122impl crate::Gates {
123 #[must_use]
124 pub fn route_permissions(mut self, table: &'static [RoutePermission]) -> Self {
125 self.permits.table = table;
126 self
127 }
128}
129
130pub trait NeedsPermission {
132 fn permission(&mut self, permission: Permission) -> &mut Self;
133}
134
135impl<A: Area> NeedsPermission for crate::PageRoute<'_, A> {
136 fn permission(&mut self, permission: Permission) -> &mut Self {
137 self.rule(|rules| rules.permission = Some(permission))
138 }
139}
140
141impl<A: Area> NeedsPermission for crate::ActionRoute<'_, A> {
142 fn permission(&mut self, permission: Permission) -> &mut Self {
143 self.rule(|rules| rules.permission = Some(permission))
144 }
145}
146
147impl Permits {
148 pub(crate) async fn open(
149 &self,
150 target: &Target,
151 session: &RequestSession,
152 db: &Db,
153 caller: Caller<'_>,
154 ) -> Result<Permissions, Box<Response>> {
155 let audience = target.audience;
156 let principal = session.principal_in(audience.name);
157 let (Some(principal), Some(members), Access::Permitted) =
158 (principal, &self.members, audience.access)
159 else {
160 return Ok(Permissions::default());
161 };
162 let granted = members
163 .grants(&db.read(), audience.name, principal)
164 .await
165 .map_err(|error| Box::new(answer::failure(error, caller)))?;
166 let permissions = Permissions {
167 granted: granted.into(),
168 #[cfg(feature = "tokens")]
169 scopes: session.scopes(),
170 };
171 let needed = target.admission.rules.permission;
172 if needed.is_some_and(|needed| permissions.holds(needed.name())) {
173 tracing::Span::current().record("permission", "granted");
174 return Ok(permissions);
175 }
176 tracing::Span::current().record("permission", "refused");
177 tracing::warn!(
178 http.route = target.pattern.as_str(),
179 area = audience.name,
180 "refused a principal without the route's permission"
181 );
182 let refusal = answer::refusal(StatusCode::FORBIDDEN, "You may not do this.", caller);
183 Err(Box::new(refusal))
184 }
185
186 pub(crate) fn prepare(
188 &mut self,
189 routes: &mut [Route],
190 members: Option<&Arc<dyn AreaMembers>>,
191 ) -> Result<()> {
192 self.members = members.cloned();
193 #[cfg(feature = "tokens")]
194 refuse_unscoped_tokens(routes)?;
195 refuse_unread_permissions(routes)?;
196 for route in routes
197 .iter_mut()
198 .filter(|route| route.audience.access == Access::Permitted)
199 {
200 let listed = self
201 .table
202 .iter()
203 .find(|entry| entry.method == route.method.as_str() && entry.path == route.path)
204 .map(|entry| entry.permission);
205 let permission = route.rules.permission.or(listed).ok_or_else(|| {
206 Error::internal_message(format!(
207 "route {} {} of area `{}` needs a permission, but the wiring names none; run `sofie gen`",
208 route.method, route.path, route.audience.name
209 ))
210 })?;
211 if self.members.is_none() {
212 return Err(Error::internal_message(format!(
213 "area `{}` admits by permission, but the app has no members to hold grants; give `App::membership` the identity's memberships",
214 route.audience.name
215 )));
216 }
217 route.rules.permission = Some(permission);
218 }
219 Ok(())
220 }
221}
222
223fn refuse_unread_permissions(routes: &[Route]) -> Result<()> {
225 let unread = routes.iter().find_map(|route| {
226 let permission = route.rules.permission?;
227 (route.audience.access != Access::Permitted).then_some((route, permission))
228 });
229 let Some((route, permission)) = unread else {
230 return Ok(());
231 };
232 Err(Error::internal_message(format!(
233 "route {} {} needs `{}`, but area `{}` does not admit by permission, so no grant would be checked; mount it in a `Permitted` area",
234 route.method,
235 route.path,
236 permission.name(),
237 route.audience.name
238 )))
239}
240
241#[cfg(feature = "tokens")]
243fn refuse_unscoped_tokens(routes: &[Route]) -> Result<()> {
244 let unscoped = routes
245 .iter()
246 .find(|route| route.audience.tokens && route.audience.access != Access::Permitted);
247 match unscoped {
248 Some(route) => Err(Error::internal_message(format!(
249 "area `{}` takes bearer tokens, but only a `Permitted` area holds a token to its scopes",
250 route.audience.name
251 ))),
252 None => Ok(()),
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 #[test]
261 fn a_grant_covers_what_matches_it_axis_by_axis() {
262 assert!(covers("order.order.*.cancel", "order.order.*.cancel"));
263 assert!(covers("order.*.*.*", "order.order.total.read"));
264 assert!(covers("*.*.*.*", "order.order.*.cancel"));
265 assert!(!covers("order.order.total.cancel", "order.order.*.cancel"));
266 assert!(!covers("order.order.*.read", "order.order.*.cancel"));
267 assert!(!covers("order.order.*", "order.order.*.read"));
268 }
269
270 #[cfg(all(feature = "toolbar", debug_assertions))]
271 #[test]
272 fn a_check_made_in_a_handler_is_recorded_for_the_toolbar() {
273 let telemetry = sofie_telemetry::capture();
274 let permissions = Permissions {
275 granted: Arc::from(["order.*.*.*".to_owned()]),
276 #[cfg(feature = "tokens")]
277 scopes: None,
278 };
279
280 assert!(permissions.can(Permission::new("order.order.*.cancel")));
281 assert!(!permissions.can_named("user.user.*.read"));
282
283 let checks: Vec<(String, String)> = telemetry
284 .spans()
285 .into_iter()
286 .filter(|span| span.name == "permission")
287 .map(|span| {
288 (
289 span.fields["otel.name"].clone(),
290 span.fields["permission"].clone(),
291 )
292 })
293 .collect();
294 assert_eq!(
295 checks,
296 [
297 ("order.order.*.cancel".to_owned(), "granted".to_owned()),
298 ("user.user.*.read".to_owned(), "refused".to_owned()),
299 ]
300 );
301 }
302
303 #[test]
304 fn a_permission_has_four_named_or_wildcard_axes() {
305 assert!(is_permission("order.order.*.cancel"));
306 assert!(is_permission("*.*.*.*"));
307 for refused in [
308 "order.order.cancel",
309 "order.Order.*.read",
310 "order..*.read",
311 "a.b.c.d.e",
312 "order.order.**.read",
313 ] {
314 assert!(!is_permission(refused), "{refused}");
315 }
316 }
317}