Skip to main content

radixdb_executor/
public_read.rs

1//! Fail-closed admission for untrusted, read-only ORM requests.
2//!
3//! The public boundary is deliberately separate from ordinary embedded SQL.
4//! A policy is bound from names to durable catalog identities once, then every
5//! execution revalidates those identities and consumes the complete result
6//! while holding one immutable catalog fence.
7
8use std::collections::{BTreeMap, BTreeSet};
9
10use radixdb_catalog::{
11    CatalogGeneration, CatalogName, CatalogPayload, ConstraintPayload, ObjectId, ObjectKind,
12};
13use radixdb_core::{DataType, Error, ParamVec, Result, Row, Value};
14use radixdb_sql::{
15    walk_expression_tree, walk_statement_physical_table_sources, walk_statement_tree, Expression,
16    SelectStatement, SimpleTableSource, Statement,
17};
18
19use crate::context::ExecutionContext;
20use crate::navigation::{bind_reference_expand_plan, ReferenceExpandPlan};
21use crate::procedural::transaction_visible_catalog;
22use crate::Executor;
23
24#[cfg(any(test, feature = "test-hooks"))]
25type PublicReadFenceTestHook = ([u8; 16], ObjectId, std::sync::Arc<dyn Fn() + Send + Sync>);
26
27#[cfg(any(test, feature = "test-hooks"))]
28static PUBLIC_READ_FENCE_TEST_HOOK: std::sync::LazyLock<
29    std::sync::Mutex<Option<PublicReadFenceTestHook>>,
30> = std::sync::LazyLock::new(|| std::sync::Mutex::new(None));
31
32#[cfg(any(test, feature = "test-hooks"))]
33static PUBLIC_READ_FENCE_TEST_HOOK_OWNER: std::sync::Mutex<()> = std::sync::Mutex::new(());
34
35#[cfg(any(test, feature = "test-hooks"))]
36#[doc(hidden)]
37pub struct PublicReadFenceTestHookGuard {
38    _owner: std::sync::MutexGuard<'static, ()>,
39}
40
41#[cfg(any(test, feature = "test-hooks"))]
42impl PublicReadFenceTestHookGuard {
43    pub fn install(
44        database_id: [u8; 16],
45        relation_id: ObjectId,
46        hook: std::sync::Arc<dyn Fn() + Send + Sync>,
47    ) -> Self {
48        let owner = PUBLIC_READ_FENCE_TEST_HOOK_OWNER
49            .lock()
50            .unwrap_or_else(|poisoned| poisoned.into_inner());
51        *PUBLIC_READ_FENCE_TEST_HOOK
52            .lock()
53            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
54            Some((database_id, relation_id, hook));
55        Self { _owner: owner }
56    }
57}
58
59#[cfg(any(test, feature = "test-hooks"))]
60impl Drop for PublicReadFenceTestHookGuard {
61    fn drop(&mut self) {
62        *PUBLIC_READ_FENCE_TEST_HOOK
63            .lock()
64            .unwrap_or_else(|poisoned| poisoned.into_inner()) = None;
65    }
66}
67
68#[cfg(any(test, feature = "test-hooks"))]
69fn run_public_read_fence_test_hook(
70    database_id: [u8; 16],
71    accesses: &BTreeMap<ObjectId, BTreeSet<ObjectId>>,
72) {
73    let hook = PUBLIC_READ_FENCE_TEST_HOOK
74        .lock()
75        .unwrap_or_else(|poisoned| poisoned.into_inner())
76        .clone();
77    if let Some((expected_database_id, relation_id, hook)) = hook {
78        if expected_database_id == database_id && accesses.contains_key(&relation_id) {
79            hook();
80        }
81    }
82}
83
84pub const PUBLIC_READ_MAX_PROJECTION: usize = 128;
85pub const PUBLIC_READ_MAX_FILTER_NODES: usize = 512;
86pub const PUBLIC_READ_MAX_NAVIGATION_DEPTH: usize = 8;
87pub const PUBLIC_READ_MAX_JOINS: usize = 8;
88pub const PUBLIC_READ_MAX_PAGE_SIZE: usize = 1_000;
89pub const PUBLIC_READ_MAX_SCANNED_ROWS: usize = 100_000;
90pub const PUBLIC_READ_MAX_RESULT_BYTES: usize = 8 * 1024 * 1024;
91pub const PUBLIC_READ_MAX_PARAMETER_BYTES: usize = 1024 * 1024;
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub struct PublicReadLimits {
95    pub max_projection: usize,
96    pub max_filter_nodes: usize,
97    pub max_navigation_depth: usize,
98    pub max_joins: usize,
99    pub max_page_size: usize,
100    pub max_scanned_rows: usize,
101    pub max_result_bytes: usize,
102    pub max_parameter_bytes: usize,
103}
104
105impl Default for PublicReadLimits {
106    fn default() -> Self {
107        Self {
108            max_projection: 64,
109            max_filter_nodes: 256,
110            max_navigation_depth: 4,
111            max_joins: 4,
112            max_page_size: 256,
113            max_scanned_rows: 25_000,
114            max_result_bytes: 2 * 1024 * 1024,
115            max_parameter_bytes: 256 * 1024,
116        }
117    }
118}
119
120impl PublicReadLimits {
121    pub fn validate(self) -> Result<Self> {
122        validate_limit(
123            "projection",
124            self.max_projection,
125            PUBLIC_READ_MAX_PROJECTION,
126        )?;
127        validate_limit(
128            "filter nodes",
129            self.max_filter_nodes,
130            PUBLIC_READ_MAX_FILTER_NODES,
131        )?;
132        validate_limit(
133            "navigation depth",
134            self.max_navigation_depth,
135            PUBLIC_READ_MAX_NAVIGATION_DEPTH,
136        )?;
137        validate_limit("JOIN count", self.max_joins, PUBLIC_READ_MAX_JOINS)?;
138        validate_limit("page size", self.max_page_size, PUBLIC_READ_MAX_PAGE_SIZE)?;
139        validate_limit(
140            "scanned rows",
141            self.max_scanned_rows,
142            PUBLIC_READ_MAX_SCANNED_ROWS,
143        )?;
144        validate_limit(
145            "result bytes",
146            self.max_result_bytes,
147            PUBLIC_READ_MAX_RESULT_BYTES,
148        )?;
149        validate_limit(
150            "parameter bytes",
151            self.max_parameter_bytes,
152            PUBLIC_READ_MAX_PARAMETER_BYTES,
153        )?;
154        Ok(self)
155    }
156}
157
158fn validate_limit(label: &str, value: usize, ceiling: usize) -> Result<()> {
159    if value == 0 || value > ceiling {
160        return Err(Error::invalid_argument(format!(
161            "public read {label} limit {value} is outside 1..={ceiling}"
162        )));
163    }
164    Ok(())
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct PublicReadRelationSpec {
169    pub name: String,
170    pub columns: Vec<String>,
171}
172
173impl PublicReadRelationSpec {
174    pub fn new(
175        name: impl Into<String>,
176        columns: impl IntoIterator<Item = impl Into<String>>,
177    ) -> Self {
178        Self {
179            name: name.into(),
180            columns: columns.into_iter().map(Into::into).collect(),
181        }
182    }
183}
184
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct PublicReadColumnBinding {
187    pub object_id: ObjectId,
188    pub name: String,
189    pub data_type: DataType,
190    pub nullable: bool,
191}
192
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct PublicReadRelationBinding {
195    pub object_id: ObjectId,
196    pub name: String,
197    pub definition_revision: u64,
198    pub columns: Vec<PublicReadColumnBinding>,
199    pub primary_key: Vec<PublicReadColumnBinding>,
200}
201
202impl PublicReadRelationBinding {
203    fn column(&self, name: &str) -> Option<&PublicReadColumnBinding> {
204        let normalized = CatalogName::new(name).ok()?;
205        self.columns
206            .iter()
207            .find(|column| column.name == normalized.normalized().as_str())
208    }
209
210    fn admits_column_id(&self, id: ObjectId) -> bool {
211        self.columns.iter().any(|column| column.object_id == id)
212    }
213}
214
215#[derive(Debug, Clone, PartialEq, Eq)]
216pub struct BoundPublicReadPolicy {
217    relations: BTreeMap<String, PublicReadRelationBinding>,
218    functions: BTreeSet<String>,
219}
220
221impl BoundPublicReadPolicy {
222    pub fn relations(&self) -> impl ExactSizeIterator<Item = &PublicReadRelationBinding> {
223        self.relations.values()
224    }
225
226    pub fn functions(&self) -> impl ExactSizeIterator<Item = &str> {
227        self.functions.iter().map(String::as_str)
228    }
229
230    pub fn relation(&self, name: &str) -> Option<&PublicReadRelationBinding> {
231        normalized_name(name)
232            .ok()
233            .and_then(|name| self.relations.get(&name))
234    }
235}
236
237#[derive(Debug, Clone, PartialEq)]
238pub struct PublicReadMaterialized {
239    pub columns: Vec<String>,
240    pub rows: Vec<Row>,
241    pub database_id: [u8; 16],
242    pub catalog_id: [u8; 16],
243    pub catalog_generation: u64,
244    pub relations: Vec<PublicReadRelationBinding>,
245}
246
247impl Executor {
248    /// Bind operator-facing names to durable catalog identities. Only ordinary
249    /// tables with an ordered primary key are publishable through this generic
250    /// boundary; sensitive row-scoped data remains procedure/view mediated.
251    #[doc(hidden)]
252    pub fn bind_public_read_policy(
253        &self,
254        relations: &[PublicReadRelationSpec],
255        functions: &[String],
256    ) -> Result<BoundPublicReadPolicy> {
257        if self.has_active_transaction() {
258            return Err(Error::invalid_argument(
259                "public read policy cannot be bound inside an explicit transaction",
260            ));
261        }
262        let _fence = self.engine.acquire_ddl_statement_fence(false);
263        let (catalog, _) = transaction_visible_catalog(self)?;
264        let mut bound = BTreeMap::new();
265        for spec in relations {
266            let relation = bind_relation(catalog.as_ref(), spec)?;
267            if bound.insert(relation.name.clone(), relation).is_some() {
268                return Err(Error::invalid_argument(
269                    "public read policy contains a duplicate relation",
270                ));
271            }
272        }
273        let mut bound_functions = BTreeSet::new();
274        for function in functions {
275            let name = normalized_name(function)?;
276            if !self.function_registry.exists(&name) {
277                return Err(Error::invalid_argument(format!(
278                    "public read function '{name}' is not a registered builtin"
279                )));
280            }
281            bound_functions.insert(name);
282        }
283        Ok(BoundPublicReadPolicy {
284            relations: bound,
285            functions: bound_functions,
286        })
287    }
288
289    /// Execute one already-rendered ORM SELECT. This seam is hidden so no
290    /// transport can expose public raw SQL; the API crate owns IR admission.
291    #[doc(hidden)]
292    pub fn execute_public_read_sql(
293        &self,
294        sql: &str,
295        params: ParamVec,
296        context: &ExecutionContext,
297        policy: &BoundPublicReadPolicy,
298        limits: PublicReadLimits,
299    ) -> Result<PublicReadMaterialized> {
300        let limits = limits.validate()?;
301        if self.has_active_transaction() {
302            return Err(Error::invalid_argument(
303                "public read cannot run inside an explicit transaction",
304            ));
305        }
306        if value_bytes(&params) > limits.max_parameter_bytes {
307            return Err(Error::invalid_argument(
308                "public read parameter-byte budget exceeded",
309            ));
310        }
311
312        let _fence = self.engine.acquire_ddl_statement_fence(false);
313        let (catalog, _) = transaction_visible_catalog(self)?;
314        revalidate_policy(catalog.as_ref(), policy)?;
315
316        let mut program = crate::dispatch::program::parse_program(sql)?;
317        if program.statements.len() != 1 {
318            return Err(public_shape("exactly one SELECT is required"));
319        }
320        let statement = program
321            .statements
322            .pop()
323            .expect("single public statement was checked");
324        let Statement::Select(select) = &statement else {
325            return Err(public_shape("only SELECT is admitted"));
326        };
327        let accesses = validate_public_select(select, policy, limits)?;
328        let navigation = bind_reference_expand_plan(self.engine.as_ref(), select)?;
329        let accesses =
330            bind_navigation_accesses(catalog.as_ref(), policy, accesses, navigation.as_ref())?;
331        crate::authorization::authorize_public_read_accesses(catalog.as_ref(), context, &accesses)?;
332
333        #[cfg(any(test, feature = "test-hooks"))]
334        run_public_read_fence_test_hook(catalog.meta().database_id(), &accesses);
335
336        let mut owned = self.fork_for_stored_function();
337        owned.ddl_fence_already_held = true;
338        let mut bounded = context.with_public_scan_limit(limits.max_scanned_rows);
339        bounded.set_params(params);
340        let mut result = owned.execute_with_context(sql, &bounded)?;
341        let columns = result.columns().to_vec();
342        let mut rows = Vec::new();
343        let mut bytes = columns.iter().map(String::len).sum::<usize>();
344        while result.next() {
345            if rows.len() == limits.max_page_size.saturating_add(1) {
346                let _ = result.close();
347                return Err(public_shape("result row limit exceeded"));
348            }
349            let row = result.take_row();
350            bytes = bytes.saturating_add(row_bytes(&row));
351            if bytes > limits.max_result_bytes {
352                let _ = result.close();
353                return Err(Error::invalid_argument(
354                    "public read result-byte budget exceeded",
355                ));
356            }
357            rows.push(row);
358        }
359        if let Some(error) = result.last_error() {
360            let _ = result.close();
361            return Err(error);
362        }
363        result.close()?;
364        let meta = catalog.meta();
365        Ok(PublicReadMaterialized {
366            columns,
367            rows,
368            database_id: meta.database_id(),
369            catalog_id: meta.catalog_id(),
370            catalog_generation: meta.catalog_generation(),
371            relations: accesses
372                .keys()
373                .filter_map(|id| {
374                    policy
375                        .relations
376                        .values()
377                        .find(|relation| relation.object_id == *id)
378                        .cloned()
379                })
380                .collect(),
381        })
382    }
383}
384
385fn bind_relation(
386    catalog: &CatalogGeneration,
387    spec: &PublicReadRelationSpec,
388) -> Result<PublicReadRelationBinding> {
389    let name = normalized_name(&spec.name)?;
390    let object = catalog
391        .find_relation(ObjectId::BOOTSTRAP_NAMESPACE, &name)
392        .map_err(catalog_error)?
393        .ok_or_else(|| Error::TableNotFound(spec.name.clone()))?;
394    if object.kind() != ObjectKind::Table {
395        return Err(public_shape("generic public reads publish tables only"));
396    }
397    let CatalogPayload::Table(table) = object.payload() else {
398        return Err(Error::internal("table object has non-table payload"));
399    };
400    if spec.columns.is_empty() {
401        return Err(public_shape("published relation must expose columns"));
402    }
403    let mut columns = Vec::with_capacity(spec.columns.len());
404    let mut seen = BTreeSet::new();
405    for requested in &spec.columns {
406        let column = catalog
407            .find_column(object.id(), requested)
408            .map_err(catalog_error)?
409            .ok_or_else(|| Error::ColumnNotFound(requested.clone()))?;
410        if !seen.insert(column.id()) {
411            return Err(public_shape("published column list contains a duplicate"));
412        }
413        columns.push(column_binding(column)?);
414    }
415    let primary_id = table.primary_key_constraint_id().ok_or_else(|| {
416        public_shape("generic public read relation requires an ordered primary key")
417    })?;
418    let primary = catalog
419        .object(primary_id)
420        .ok_or_else(|| Error::internal("primary-key catalog object is missing"))?;
421    let CatalogPayload::Constraint(ConstraintPayload::PrimaryKey { local_column_ids }) =
422        primary.payload()
423    else {
424        return Err(Error::internal(
425            "table primary-key pointer does not reference a primary key",
426        ));
427    };
428    let mut primary_key = Vec::with_capacity(local_column_ids.len());
429    for id in local_column_ids {
430        let column = columns
431            .iter()
432            .find(|column| column.object_id == *id)
433            .ok_or_else(|| public_shape("every primary-key column must be explicitly published"))?;
434        if !column.data_type.is_orderable() {
435            return Err(public_shape("public pagination key is not orderable"));
436        }
437        primary_key.push(column.clone());
438    }
439    Ok(PublicReadRelationBinding {
440        object_id: object.id(),
441        name,
442        definition_revision: object.definition_revision(),
443        columns,
444        primary_key,
445    })
446}
447
448fn column_binding(object: &radixdb_catalog::CatalogObject) -> Result<PublicReadColumnBinding> {
449    let CatalogPayload::Column(column) = object.payload() else {
450        return Err(Error::internal("column object has non-column payload"));
451    };
452    Ok(PublicReadColumnBinding {
453        object_id: object.id(),
454        name: object.name().normalized().as_str().to_owned(),
455        data_type: column.data_type().logical_type(),
456        nullable: column.nullable(),
457    })
458}
459
460fn revalidate_policy(catalog: &CatalogGeneration, policy: &BoundPublicReadPolicy) -> Result<()> {
461    for relation in policy.relations.values() {
462        let current = catalog
463            .object(relation.object_id)
464            .ok_or_else(|| public_shape("published relation identity is stale or was dropped"))?;
465        if current.kind() != ObjectKind::Table
466            || current.name().normalized().as_str() != relation.name
467            || current.definition_revision() != relation.definition_revision
468        {
469            return Err(public_shape("published relation identity is stale"));
470        }
471        for column in relation.columns.iter().chain(&relation.primary_key) {
472            let current = catalog
473                .object(column.object_id)
474                .ok_or_else(|| public_shape("published column identity is stale or was dropped"))?;
475            let CatalogPayload::Column(payload) = current.payload() else {
476                return Err(public_shape("published column identity changed kind"));
477            };
478            if current.parent_id() != Some(relation.object_id)
479                || current.name().normalized().as_str() != column.name
480                || payload.data_type().logical_type() != column.data_type
481                || payload.nullable() != column.nullable
482            {
483                return Err(public_shape("published column identity is stale"));
484            }
485        }
486    }
487    Ok(())
488}
489
490type BoundAccesses = BTreeMap<ObjectId, BTreeSet<ObjectId>>;
491
492fn validate_public_select(
493    select: &SelectStatement,
494    policy: &BoundPublicReadPolicy,
495    limits: PublicReadLimits,
496) -> Result<BoundAccesses> {
497    if select.with.is_some()
498        || select.distinct
499        || !select.distinct_on.is_empty()
500        || !select.group_by.columns.is_empty()
501        || select.having.is_some()
502        || !select.window_defs.is_empty()
503        || !select.set_operations.is_empty()
504        || select.offset.is_some()
505    {
506        return Err(public_shape(
507            "query feature is outside the public read subset",
508        ));
509    }
510    if select.columns.is_empty() || select.columns.len() > limits.max_projection {
511        return Err(public_shape(
512            "projection width is outside the admitted limit",
513        ));
514    }
515    let limit = match select.limit.as_deref() {
516        Some(Expression::IntegerLiteral(value)) if value.value > 0 => value.value as usize,
517        _ => return Err(public_shape("a positive constant LIMIT is required")),
518    };
519    if limit > limits.max_page_size.saturating_add(1) {
520        return Err(public_shape("page size exceeds the admitted limit"));
521    }
522
523    let mut sources = Vec::<SimpleTableSource>::new();
524    walk_statement_physical_table_sources(&Statement::Select(select.clone()), &mut |source| {
525        sources.push(source.clone());
526    });
527    if sources.is_empty() || sources.len() > limits.max_joins.saturating_add(1) {
528        return Err(public_shape(
529            "relation/JOIN count is outside the admitted limit",
530        ));
531    }
532    let mut aliases = BTreeMap::<String, &PublicReadRelationBinding>::new();
533    for source in &sources {
534        if source.as_of.is_some() {
535            return Err(public_shape(
536                "temporal table sources are not publicly admitted",
537            ));
538        }
539        let relation = policy
540            .relation(source.name.value())
541            .ok_or_else(|| public_shape("relation is not explicitly published"))?;
542        let alias = source.alias.as_ref().map_or_else(
543            || source.name.value_lower().to_owned(),
544            |alias| alias.value_lower().to_owned(),
545        );
546        if aliases.insert(alias, relation).is_some() {
547            return Err(public_shape("relation alias is ambiguous"));
548        }
549    }
550
551    let mut filter_nodes = 0usize;
552    if let Some(filter) = &select.where_clause {
553        walk_expression_tree(filter, &mut |_| {
554            filter_nodes = filter_nodes.saturating_add(1)
555        });
556    }
557    if let Some(source) = &select.table_expr {
558        count_public_join_predicate_nodes(source, &mut filter_nodes);
559    }
560    if filter_nodes > limits.max_filter_nodes {
561        return Err(public_shape("filter complexity exceeds the admitted limit"));
562    }
563
564    let mut error = None;
565    let mut joins = 0usize;
566    let mut accesses = BoundAccesses::new();
567    walk_statement_tree(&Statement::Select(select.clone()), &mut |expression| {
568        if error.is_some() {
569            return;
570        }
571        let result = match expression {
572            Expression::TableSource(_) => Ok(()),
573            Expression::JoinSource(join) => {
574                joins = joins.saturating_add(1);
575                let kind = join.join_type.to_uppercase();
576                if !matches!(kind.as_str(), "INNER" | "CROSS")
577                    || !join.using_columns.is_empty()
578                    || (kind == "INNER" && join.condition.is_none())
579                {
580                    Err(public_shape("only INNER/CROSS JOIN ... ON is admitted"))
581                } else {
582                    Ok(())
583                }
584            }
585            Expression::Identifier(identifier) => {
586                if aliases.len() != 1 {
587                    Err(public_shape(
588                        "unqualified columns require one relation source",
589                    ))
590                } else {
591                    admit_named_column(
592                        aliases.values().next().expect("one alias"),
593                        identifier.value(),
594                        &mut accesses,
595                    )
596                }
597            }
598            Expression::QualifiedIdentifier(identifier) => {
599                let depth = identifier.component_count().saturating_sub(1);
600                if depth > limits.max_navigation_depth {
601                    Err(public_shape("navigation depth exceeds the admitted limit"))
602                } else if identifier.component_count() == 2 {
603                    aliases
604                        .get(identifier.qualifier.value_lower())
605                        .ok_or_else(|| public_shape("column qualifier is not a relation alias"))
606                        .and_then(|relation| {
607                            admit_named_column(relation, identifier.name.value(), &mut accesses)
608                        })
609                } else if aliases.contains_key(identifier.qualifier.value_lower()) {
610                    Ok(())
611                } else {
612                    Err(public_shape("navigation root is not a relation alias"))
613                }
614            }
615            Expression::FunctionCall(function) => {
616                let name = function.function.to_lowercase();
617                if policy.functions.contains(name.as_str()) {
618                    Ok(())
619                } else {
620                    Err(public_shape("function is not explicitly allowlisted"))
621                }
622            }
623            Expression::Star(_)
624            | Expression::QualifiedStar(_)
625            | Expression::SubquerySource(_)
626            | Expression::ValuesSource(_)
627            | Expression::CteReference(_)
628            | Expression::FunctionTableSource(_)
629            | Expression::Exists(_)
630            | Expression::AllAny(_)
631            | Expression::ScalarSubquery(_)
632            | Expression::Window(_) => Err(public_shape(
633                "stars, subqueries, derived sources and windows are not publicly admitted",
634            )),
635            _ => Ok(()),
636        };
637        if let Err(value) = result {
638            error = Some(value);
639        }
640    });
641    if let Some(error) = error {
642        return Err(error);
643    }
644    if joins > limits.max_joins {
645        return Err(public_shape("JOIN count exceeds the admitted limit"));
646    }
647    Ok(accesses)
648}
649
650fn count_public_join_predicate_nodes(expression: &Expression, nodes: &mut usize) {
651    if let Expression::JoinSource(join) = expression {
652        count_public_join_predicate_nodes(&join.left, nodes);
653        count_public_join_predicate_nodes(&join.right, nodes);
654        if let Some(condition) = &join.condition {
655            walk_expression_tree(condition, &mut |_| *nodes = nodes.saturating_add(1));
656        }
657    }
658}
659
660fn admit_named_column(
661    relation: &PublicReadRelationBinding,
662    name: &str,
663    accesses: &mut BoundAccesses,
664) -> Result<()> {
665    let column = relation
666        .column(name)
667        .ok_or_else(|| public_shape("column is not explicitly published"))?;
668    accesses
669        .entry(relation.object_id)
670        .or_default()
671        .insert(column.object_id);
672    Ok(())
673}
674
675fn bind_navigation_accesses(
676    catalog: &CatalogGeneration,
677    policy: &BoundPublicReadPolicy,
678    mut accesses: BoundAccesses,
679    plan: Option<&ReferenceExpandPlan>,
680) -> Result<BoundAccesses> {
681    let Some(plan) = plan else {
682        return Ok(accesses);
683    };
684    for edge in plan.edges() {
685        for column in std::iter::once(edge.source_column())
686            .chain(std::iter::once(edge.target_key_column()))
687            .chain(edge.required_columns())
688        {
689            let table = catalog
690                .find_relation(ObjectId::BOOTSTRAP_NAMESPACE, column.table().table_name())
691                .map_err(catalog_error)?
692                .ok_or_else(|| public_shape("navigation relation is absent from catalog"))?;
693            let published = policy
694                .relations
695                .values()
696                .find(|published| published.object_id == table.id())
697                .ok_or_else(|| public_shape("navigation target relation is not published"))?;
698            let CatalogPayload::Table(table_payload) = table.payload() else {
699                return Err(public_shape("navigation target is not a table"));
700            };
701            let id = *table_payload
702                .column_ids()
703                .get(column.ordinal())
704                .ok_or_else(|| public_shape("navigation column ordinal is stale"))?;
705            if !published.admits_column_id(id) {
706                return Err(public_shape("navigation column is not published"));
707            }
708            accesses.entry(table.id()).or_default().insert(id);
709        }
710    }
711    Ok(accesses)
712}
713
714fn normalized_name(value: &str) -> Result<String> {
715    CatalogName::new(value)
716        .map(|name| name.normalized().as_str().to_owned())
717        .map_err(catalog_error)
718}
719
720fn catalog_error(error: radixdb_catalog::CatalogError) -> Error {
721    Error::invalid_argument(format!("catalog binding failed: {error}"))
722}
723
724fn public_shape(detail: &str) -> Error {
725    Error::invalid_argument(format!("public read rejected: {detail}"))
726}
727
728fn value_bytes(values: &[Value]) -> usize {
729    values.iter().map(single_value_bytes).sum()
730}
731
732fn row_bytes(row: &Row) -> usize {
733    row.iter().map(single_value_bytes).sum::<usize>()
734        + row.len().saturating_mul(std::mem::size_of::<Value>())
735}
736
737fn single_value_bytes(value: &Value) -> usize {
738    match value {
739        Value::Text(value) => value.len(),
740        Value::Extension(value) => value.len(),
741        _ => std::mem::size_of::<Value>(),
742    }
743}