Skip to main content

micromegas_auth/
groups.rs

1//! Local group membership: the `groups`/`group_members` tables (migration v10,
2//! `rust/ingestion/src/sql_migration.rs`), a whole-table snapshot cache ([`crate::groups::DbGroupsSource`])
3//! mirroring [`crate::db_audience_grants::DbAudienceGrantsSource`], and [`crate::groups::GroupGraph`], the
4//! in-memory closure resolver over a loaded snapshot.
5//!
6//! A `group_members.member` row is a selector in exactly the vocabulary
7//! `audience_grants.selector` uses (`*`, `user:<email>`, `group:<name>`), so nesting is the
8//! `group:` arm of the same predicate rather than a special case -- see [`crate::groups::GroupGraph::closure`].
9
10use crate::db_api_key::resolve_u64;
11use crate::db_snapshot::{SnapshotLoader, SnapshotSource};
12use crate::policy::valid_selector;
13use anyhow::{Context, Result, anyhow};
14use async_trait::async_trait;
15use sqlx::{PgPool, Row};
16use std::collections::{BTreeMap, BTreeSet, VecDeque};
17
18/// The reserved group whose membership is admin-ness -- `AuthContext::is_admin` is exactly
19/// "does this caller's closure contain this name".
20pub const ADMINS_GROUP: &str = "admins";
21
22/// `true` if `name` is a valid group name: `[A-Za-z0-9_-]{1,255}` -- the same charset
23/// `is_valid_audience` uses, checked in bytes, so a group name is URL-safe and a distinct kind of
24/// thing from an email.
25pub fn is_valid_group_name(name: &str) -> bool {
26    !name.is_empty()
27        && name.len() <= 255
28        && name
29            .bytes()
30            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
31}
32
33/// A loaded, in-memory snapshot of the `groups`/`group_members` tables, and the closure resolver
34/// over it. Sync and infallible once built: every method here is a walk over an already-loaded
35/// snapshot, never a query.
36#[derive(Debug, Clone, Default)]
37pub struct GroupGraph {
38    /// Every group name, whether or not it has any members.
39    groups: BTreeSet<String>,
40    /// member selector (`*`, `user:<email>`, or `group:<name>`) -> the groups it is a *direct*
41    /// member of.
42    members_of: BTreeMap<String, Vec<String>>,
43}
44
45impl GroupGraph {
46    /// Builds a graph from `(name)` rows out of `groups` and `(group_name, member)` rows out of
47    /// `group_members`. Re-runs the name charset and [`valid_selector`] checks the way
48    /// `AudienceGrants::from_rows` does, so a row that slipped past the tables' own `CHECK`
49    /// constraints (e.g. via a direct `psql` session) fails the snapshot load rather than
50    /// reaching a decision.
51    pub fn from_rows(
52        groups: impl IntoIterator<Item = String>,
53        members: impl IntoIterator<Item = (String, String)>,
54    ) -> Result<Self> {
55        let mut group_set = BTreeSet::new();
56        for name in groups {
57            if !is_valid_group_name(&name) {
58                return Err(anyhow!(
59                    "invalid group row: {name:?} is not a valid group name -- must match \
60                     [A-Za-z0-9_-]{{1,255}}"
61                ));
62            }
63            group_set.insert(name);
64        }
65        let mut members_of: BTreeMap<String, Vec<String>> = BTreeMap::new();
66        for (group_name, member) in members {
67            if !is_valid_group_name(&group_name) {
68                return Err(anyhow!(
69                    "invalid group_members row: {group_name:?} is not a valid group name -- \
70                     must match [A-Za-z0-9_-]{{1,255}}"
71                ));
72            }
73            if !valid_selector(&member) {
74                return Err(anyhow!(
75                    "invalid group_members row: selector {member:?} for group {group_name:?} \
76                     must be '*', 'user:<id>', or 'group:<id>'"
77                ));
78            }
79            members_of.entry(member).or_default().push(group_name);
80        }
81        Ok(Self {
82            groups: group_set,
83            members_of,
84        })
85    }
86
87    /// Every known group name, sorted.
88    pub fn group_names(&self) -> impl Iterator<Item = &String> {
89        self.groups.iter()
90    }
91
92    /// Whether `name` is a known group.
93    pub fn contains_group(&self, name: &str) -> bool {
94        self.groups.contains(name)
95    }
96
97    /// Breadth-first walk upward, starting from `initial` and repeatedly following
98    /// `members_of["group:g"]` for each newly reached group `g`. The visited set is what
99    /// tolerates a cycle at read time.
100    fn walk_upward(&self, initial: BTreeSet<String>) -> BTreeSet<String> {
101        let mut visited = initial.clone();
102        let mut queue: VecDeque<String> = initial.into_iter().collect();
103        while let Some(g) = queue.pop_front() {
104            if let Some(next) = self.members_of.get(&format!("group:{g}")) {
105                for ng in next {
106                    if visited.insert(ng.clone()) {
107                        queue.push_back(ng.clone());
108                    }
109                }
110            }
111        }
112        visited
113    }
114
115    /// The caller's resolved, transitive group membership: a breadth-first walk **upward** from
116    /// the caller, seeded at the groups listed under the selectors `*` and `user:<email>` (when
117    /// `email` is `Some`). Returned sorted, deduplicated. Sync and infallible: the graph is an
118    /// already-loaded snapshot.
119    pub fn closure(&self, email: Option<&str>) -> Vec<String> {
120        let mut initial = BTreeSet::new();
121        if let Some(gs) = self.members_of.get("*") {
122            initial.extend(gs.iter().cloned());
123        }
124        if let Some(email) = email
125            && let Some(gs) = self.members_of.get(&format!("user:{email}"))
126        {
127            initial.extend(gs.iter().cloned());
128        }
129        self.walk_upward(initial).into_iter().collect()
130    }
131
132    /// `true` when the wildcard selector `*` reaches [`ADMINS_GROUP`] -- directly (`('admins',
133    /// '*')`) or through nesting. Reuses the same upward walk as [`Self::closure`], seeded at `*`
134    /// alone (no caller email).
135    pub fn has_wildcard_admin(&self) -> bool {
136        self.closure(None).iter().any(|g| g == ADMINS_GROUP)
137    }
138
139    /// `true` when `target` is reachable from *some* principal -- the wildcard `*` selector or
140    /// any `user:<email>` selector present in the snapshot -- directly or through nesting.
141    /// Generalizes [`Self::has_wildcard_admin`] (which seeds the walk from `*` alone) to seed
142    /// from every base selector on record, so a caller can check "would `target` still be
143    /// reachable by anyone" rather than "by `*` specifically" -- e.g. before removing a
144    /// membership that would strand `ADMINS_GROUP` behind a user-only or nested-only path.
145    pub fn any_principal_reaches(&self, target: &str) -> bool {
146        let mut initial = BTreeSet::new();
147        for (selector, groups) in &self.members_of {
148            if selector == "*" || selector.starts_with("user:") {
149                initial.extend(groups.iter().cloned());
150            }
151        }
152        self.walk_upward(initial).iter().any(|g| g == target)
153    }
154
155    /// Whether adding `group:nested` as a member of `group` would create a cycle: `nested ==
156    /// group`, or `nested` is already reachable upward from `group` (a walk seeded at `group`
157    /// reaches it -- i.e. `group` is already, directly or transitively, nested into `nested`).
158    pub fn nesting_would_cycle(&self, group: &str, nested: &str) -> bool {
159        if nested == group {
160            return true;
161        }
162        self.walk_upward(BTreeSet::from([group.to_string()]))
163            .contains(nested)
164    }
165}
166
167/// Cache-TTL knob for [`DbGroupsSource`], read from env with a default.
168#[derive(Clone, Copy, Debug)]
169pub struct DbGroupsConfig {
170    /// `MICROMEGAS_AUTH_CACHE_TTL_SECONDS`, default 60 -- a single flat, unprefixed knob (no
171    /// `{prefix}_` role variant) shared with `DbApiKeyConfig`/`DbAudienceGrantsConfig`'s own
172    /// positive-cache TTL: one value governs the API-key, audience-grant, and group snapshot
173    /// caches process-wide, across every role. Membership and admin changes take effect within
174    /// this TTL per process; that is the documented latency.
175    pub cache_ttl_secs: u64,
176}
177
178impl DbGroupsConfig {
179    /// Resolves the flat, unprefixed `MICROMEGAS_AUTH_CACHE_TTL_SECONDS` knob directly -- there
180    /// is exactly one value for this knob process-wide, so `prefix` is accepted (for call-site
181    /// symmetry with every other `from_env_with_prefix`) but not consulted here.
182    pub fn from_env_with_prefix(_prefix: &str) -> Self {
183        Self {
184            cache_ttl_secs: resolve_u64("", "AUTH_CACHE_TTL_SECONDS", 60),
185        }
186    }
187}
188
189/// [`SnapshotLoader`] for the `groups`/`group_members` tables.
190#[derive(Debug)]
191pub struct GroupsLoader;
192
193#[async_trait]
194impl SnapshotLoader for GroupsLoader {
195    type Snapshot = GroupGraph;
196    const NAME: &'static str = "group store";
197
198    /// Runs both `SELECT`s in one read transaction and builds a [`GroupGraph`], so a concurrent
199    /// `DELETE ... CASCADE` cannot yield a snapshot with members of a group the `groups` query
200    /// missed.
201    async fn fetch(pool: &PgPool) -> Result<GroupGraph> {
202        let mut tx = pool
203            .begin()
204            .await
205            .context("starting group store read transaction")?;
206        let group_rows = sqlx::query("SELECT name FROM groups")
207            .fetch_all(&mut *tx)
208            .await
209            .context("querying groups")?;
210        let mut names = Vec::with_capacity(group_rows.len());
211        for row in group_rows {
212            let name: String = row.try_get("name").context("reading name")?;
213            names.push(name);
214        }
215        let member_rows = sqlx::query("SELECT group_name, member FROM group_members")
216            .fetch_all(&mut *tx)
217            .await
218            .context("querying group_members")?;
219        let mut members = Vec::with_capacity(member_rows.len());
220        for row in member_rows {
221            let group_name: String = row.try_get("group_name").context("reading group_name")?;
222            let member: String = row.try_get("member").context("reading member")?;
223            members.push((group_name, member));
224        }
225        tx.commit()
226            .await
227            .context("committing group store read transaction")?;
228        GroupGraph::from_rows(names, members)
229    }
230
231    fn count_refresh_error() {
232        micromegas_tracing::imetric!("group_refresh_error_count", "count", 1_u64);
233    }
234}
235
236/// The whole-table snapshot cache described in the module doc comment.
237pub type DbGroupsSource = SnapshotSource<GroupsLoader>;