Skip to main content

micromegas_analytics/
audience.rs

1//! The precedence rule every audience-carrying reader follows, and the two SQL fragments
2//! shared by the writer (`lakehouse::blocks_view`) and both enforcement prongs
3//! (`lakehouse::audience_guard`, `lakehouse::ownership_rewrite`) -- and by `metadata.rs`, the
4//! JIT / per-process path.
5//!
6//! **A row's own `audience` column is the authoritative label for that row.** It is the
7//! authenticated fact recorded at the moment the row was written (`processes.audience`,
8//! `streams.audience`, `blocks.audience`). A NULL column means the row predates this stage
9//! and resolves to the deployment's `MICROMEGAS_DEFAULT_AUDIENCE`. No row's audience is ever
10//! derived from another row's for any row that carries the column; a reader with no
11//! `audience` column of its own still resolves through the owning process/stream row.
12//!
13//! `check_process_audience_conflict` (and its stream-side mirror
14//! `check_stream_audience_conflict`) still governs only the `processes` (or `streams`) row it
15//! re-registers.
16
17/// `COALESCE(<qualifier>.audience, $<param>)` -- a row's own stamp, or the deployment's
18/// `MICROMEGAS_DEFAULT_AUDIENCE` for a row written before this stage (a NULL column). `qualifier`
19/// is inlined as SQL text, so it must be a trusted table name or alias, never user input; the
20/// default stays a bind parameter, since it is operator-supplied config.
21///
22/// This is the shared shape of every site that reads an audience out of Postgres off a physical
23/// column -- the `blocks` view's `data_sql`, `metadata::find_process`, and `audience_guard`'s
24/// `owner_query_sql`. `param` is the 1-based placeholder index the caller will bind the default
25/// to.
26pub fn coalesced_audience_column(qualifier: &str, param: usize) -> String {
27    format!("COALESCE({qualifier}.audience, ${param})")
28}
29
30/// True when `left_qualifier.audience` and `right_qualifier.audience` are both set but
31/// disagree. NULL-tolerant: a NULL `audience` on either side does not count as a mismatch, so
32/// a legacy row on either side of the join passes through cleanly. Both qualifiers must be
33/// trusted table names/aliases, never user input.
34pub fn audience_column_mismatch(left_qualifier: &str, right_qualifier: &str) -> String {
35    format!(
36        "{left_qualifier}.audience IS NOT NULL AND {right_qualifier}.audience IS NOT NULL \
37         AND {left_qualifier}.audience <> {right_qualifier}.audience"
38    )
39}
40
41/// The audience assigned to anything that arrives without one. Matches
42/// `micromegas_auth::policy::PUBLIC_AUDIENCE`, kept as a local copy for the same crate-boundary
43/// reason [`default_audience_from_env`] duplicates the charset check.
44pub const DEFAULT_AUDIENCE: &str = "public";
45
46/// `true` if `aud` is a valid audience name: `[A-Za-z0-9_-]{1,255}`, checked in bytes.
47///
48/// A third copy of the same predicate, beside `micromegas_auth::policy::is_valid_audience` and
49/// `micromegas_ingestion::write_audience`'s: `micromegas-analytics` does not depend on
50/// `micromegas-auth`, and the lakehouse must validate this knob without acquiring that
51/// dependency. Keep the copies in step if the charset ever changes.
52fn is_valid_audience(aud: &str) -> bool {
53    !aud.is_empty()
54        && aud.len() <= 255
55        && aud
56            .bytes()
57            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
58}
59
60/// Resolves `MICROMEGAS_DEFAULT_AUDIENCE` -- the audience a never-stamped row is read as --
61/// defaulting to [`DEFAULT_AUDIENCE`] when unset. Malformed ⇒ `Err`, so a typo fails startup
62/// rather than silently relabelling every legacy row.
63///
64/// Read **once**, by `LakehouseContext`, and handed to all read sites from there. Every role that
65/// materializes or queries the six global views builds a `LakehouseContext`, and the maintenance
66/// role is the one that bakes the resolved value into partitions -- so a deployment that sets
67/// this knob on only some roles gets partitions labelled inconsistently. Changing it is not a
68/// routine operation: already-written partitions keep the value they were materialized under
69/// until they are regenerated.
70///
71/// This is not a per-caller query-side setting but a property of the lake's contents. Two other
72/// code readers of the same variable fall back to this same unprefixed name:
73/// `micromegas_auth::policy::default_audience_from_env`, on the key-minting side, and the
74/// ingestion HTTP edge's own call in `serve_ingestion`, which resolves the default a credential
75/// with no bound audience is stamped with at write time.
76/// Trimming and its warning match both, so one env value can never be accepted by one role and
77/// rejected by another.
78pub fn default_audience_from_env() -> anyhow::Result<String> {
79    const VAR: &str = "MICROMEGAS_DEFAULT_AUDIENCE";
80    let resolved = match std::env::var(VAR) {
81        Ok(raw) => {
82            let trimmed = raw.trim();
83            if trimmed != raw {
84                micromegas_tracing::warn!(
85                    "{VAR}: value {raw:?} has leading or trailing whitespace -- using {trimmed:?}"
86                );
87            }
88            if !is_valid_audience(trimmed) {
89                anyhow::bail!(
90                    "{VAR}: {trimmed:?} is not a valid audience name -- must match \
91                     [A-Za-z0-9_-]{{1,255}}"
92                );
93            }
94            trimmed.to_string()
95        }
96        Err(_) => DEFAULT_AUDIENCE.to_owned(),
97    };
98    micromegas_tracing::info!("{VAR}: default audience = {resolved}");
99    Ok(resolved)
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn coalesced_column_wraps_the_qualifier_and_binds_the_default() {
108        let coalesced = coalesced_audience_column("processes", 3);
109        assert_eq!(coalesced, "COALESCE(processes.audience, $3)");
110        // The default is bound, never inlined: an operator-supplied label must not reach the
111        // SQL text even though `qualifier` does.
112        assert!(!coalesced.contains(DEFAULT_AUDIENCE));
113    }
114
115    #[test]
116    fn each_read_site_can_pick_its_own_placeholder_index() {
117        // `blocks`' `data_sql` binds $1/$2 for the insert range, so its default is $3;
118        // `find_process` binds $1 for the process id, so its default is $2.
119        assert!(coalesced_audience_column("processes", 2).ends_with(", $2)"));
120        assert!(coalesced_audience_column("blocks", 3).ends_with(", $3)"));
121    }
122
123    #[test]
124    fn mismatch_emits_the_expected_null_tolerant_text() {
125        let mismatch = audience_column_mismatch("blocks", "streams");
126        assert_eq!(
127            mismatch,
128            "blocks.audience IS NOT NULL AND streams.audience IS NOT NULL AND blocks.audience <> streams.audience"
129        );
130    }
131
132    #[test]
133    fn mismatch_qualifiers_are_not_swapped() {
134        let mismatch = audience_column_mismatch("blocks", "processes");
135        assert!(mismatch.starts_with("blocks.audience IS NOT NULL"));
136        assert!(mismatch.contains("processes.audience IS NOT NULL"));
137        assert!(mismatch.ends_with("blocks.audience <> processes.audience"));
138    }
139
140    #[test]
141    fn charset_matches_the_other_two_copies() {
142        assert!(is_valid_audience("public"));
143        assert!(is_valid_audience("team-alpha_1"));
144        assert!(!is_valid_audience(""));
145        assert!(!is_valid_audience("team alpha"));
146        assert!(!is_valid_audience("user:alice@example.com"));
147        assert!(!is_valid_audience(&"a".repeat(256)));
148        assert!(is_valid_audience(&"a".repeat(255)));
149    }
150}