Skip to main content

gatekeep_sqlx/fragment/
tenant.rs

1/// Maximum portable SQL identifier length accepted by this adapter.
2pub const MAX_TENANT_IDENTIFIER_BYTES: usize = 63;
3
4/// Which component of a qualified tenant column failed validation.
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6pub enum TenantIdentifierPart {
7    /// The table identifier.
8    Table,
9    /// The column identifier.
10    Column,
11}
12
13/// Failure while constructing a tenant column identifier.
14#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
15pub enum TenantColumnError {
16    /// The identifier was empty or exceeded the portable bound.
17    #[error("tenant {part:?} identifier must be 1..={max} ASCII bytes")]
18    InvalidLength {
19        /// Identifier component that failed validation.
20        part: TenantIdentifierPart,
21        /// Maximum accepted byte length.
22        max: usize,
23    },
24    /// The identifier was not an unquoted SQL identifier.
25    #[error("tenant {part:?} identifier {value:?} is not a safe unquoted SQL identifier")]
26    InvalidCharacters {
27        /// Identifier component that failed validation.
28        part: TenantIdentifierPart,
29        /// Rejected static identifier.
30        value: &'static str,
31    },
32}
33
34/// Validated application-owned table and column names for tenant filtering.
35///
36/// Both components use the portable unquoted SQL identifier grammar
37/// `[A-Za-z_][A-Za-z0-9_]*`. SQL operators, literals, comments, quoting, and
38/// qualified expressions therefore cannot enter the generated predicate.
39/// Tenant values remain typed `SQLx` binds.
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub struct TenantColumn {
42    table: &'static str,
43    column: &'static str,
44}
45
46impl TenantColumn {
47    /// Creates a validated qualified tenant column.
48    ///
49    /// # Errors
50    ///
51    /// Returns [`TenantColumnError`] for empty, overlong, or non-identifier
52    /// table/column components.
53    pub const fn new(table: &'static str, column: &'static str) -> Result<Self, TenantColumnError> {
54        match validate_tenant_identifier(table, TenantIdentifierPart::Table) {
55            Ok(()) => {}
56            Err(error) => return Err(error),
57        }
58
59        match validate_tenant_identifier(column, TenantIdentifierPart::Column) {
60            Ok(()) => {}
61            Err(error) => return Err(error),
62        }
63        Ok(Self { table, column })
64    }
65
66    /// Returns the validated table identifier.
67    #[must_use]
68    pub const fn table(self) -> &'static str {
69        self.table
70    }
71
72    /// Returns the validated column identifier.
73    #[must_use]
74    pub const fn column(self) -> &'static str {
75        self.column
76    }
77
78    /// Renders the qualified identifier from already validated components.
79    #[must_use]
80    pub fn qualified(self) -> String {
81        format!("{}.{}", self.table, self.column)
82    }
83}
84
85const fn validate_tenant_identifier(
86    value: &'static str,
87    part: TenantIdentifierPart,
88) -> Result<(), TenantColumnError> {
89    let bytes = value.as_bytes();
90    if bytes.is_empty() || bytes.len() > MAX_TENANT_IDENTIFIER_BYTES {
91        return Err(TenantColumnError::InvalidLength {
92            part,
93            max: MAX_TENANT_IDENTIFIER_BYTES,
94        });
95    }
96
97    let Some((first, mut remaining)) = bytes.split_first() else {
98        return Err(TenantColumnError::InvalidLength {
99            part,
100            max: MAX_TENANT_IDENTIFIER_BYTES,
101        });
102    };
103
104    if !(first.is_ascii_alphabetic() || *first == b'_') {
105        return Err(TenantColumnError::InvalidCharacters { part, value });
106    }
107
108    while let [byte, rest @ ..] = remaining {
109        if !(byte.is_ascii_alphanumeric() || *byte == b'_') {
110            return Err(TenantColumnError::InvalidCharacters { part, value });
111        }
112        remaining = rest;
113    }
114
115    if is_reserved_tenant_identifier(value) {
116        return Err(TenantColumnError::InvalidCharacters { part, value });
117    }
118
119    Ok(())
120}
121
122const fn is_reserved_tenant_identifier(value: &str) -> bool {
123    matches_ascii_case_insensitive(
124        value,
125        &[
126            "all", "and", "as", "between", "by", "case", "else", "end", "exists", "false", "from",
127            "group", "having", "in", "is", "join", "like", "limit", "not", "null", "offset", "on",
128            "or", "order", "select", "then", "true", "union", "when", "where",
129        ],
130    )
131}
132
133const fn matches_ascii_case_insensitive(value: &str, mut candidates: &[&str]) -> bool {
134    while let [candidate, rest @ ..] = candidates {
135        if value.eq_ignore_ascii_case(candidate) {
136            return true;
137        }
138        candidates = rest;
139    }
140    false
141}