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>;