Skip to main content

rskit_database/
tenant.rs

1//! Tenant-scoping helpers for multi-tenant database queries.
2//!
3//! Mirrors gokit's `database/tenant.go`.  Provides:
4//!
5//! - [`TenantScope`] — a builder for constructing tenant-filtered query
6//!   predicate fragments.
7
8use std::num::NonZeroUsize;
9
10/// Helper for building tenant-scoped SQL `WHERE` clauses.
11///
12/// Mirrors gokit's `ScopeToTenant` by associating a field name with a tenant
13/// value. Adapter crates decide how to bind the returned value.
14///
15/// # Examples
16///
17/// ```
18/// use rskit_database::TenantScope;
19/// use std::num::NonZeroUsize;
20///
21/// let scope = TenantScope::new("workspace_id", "ws-123").unwrap();
22/// assert_eq!(
23///     scope.where_clause(NonZeroUsize::new(1).unwrap()),
24///     "workspace_id = $1",
25/// );
26/// assert_eq!(scope.value(), "ws-123");
27/// ```
28#[derive(Debug, Clone)]
29pub struct TenantScope {
30    column: String,
31    value: String,
32}
33
34impl TenantScope {
35    /// Create a new [`TenantScope`] for the given column and value.
36    ///
37    /// # Errors
38    /// Returns an error when the column is not a safe SQL identifier path or
39    /// the tenant value is empty.
40    pub fn new(
41        column: impl Into<String>,
42        value: impl Into<String>,
43    ) -> rskit_errors::AppResult<Self> {
44        let column = column.into();
45        let value = value.into();
46        validate_identifier_path(&column)?;
47        if value.is_empty() {
48            return Err(rskit_errors::AppError::invalid_input(
49                "tenant.value",
50                "tenant value is required",
51            ));
52        }
53        Ok(Self { column, value })
54    }
55
56    /// Return a `WHERE` clause fragment like `"workspace_id = $1"`.
57    ///
58    /// `param_index` is the positional parameter number for the bind
59    /// placeholder (1-based for PostgreSQL `$N` syntax).
60    #[must_use]
61    pub fn where_clause(&self, param_index: NonZeroUsize) -> String {
62        let param_index = param_index.get();
63        format!("{} = ${param_index}", self.column)
64    }
65
66    /// Return the tenant value to bind.
67    #[must_use]
68    pub fn value(&self) -> &str {
69        &self.value
70    }
71
72    /// Return the column name.
73    #[must_use]
74    pub fn column(&self) -> &str {
75        &self.column
76    }
77
78    /// Append a tenant-scoped `WHERE` clause to the given SQL query.
79    ///
80    /// Returns the modified query with ` WHERE column = $N` appended, where
81    /// `N` is `param_index`.
82    #[must_use]
83    pub fn apply(&self, query: &str, param_index: NonZeroUsize) -> String {
84        format!("{query} WHERE {}", self.where_clause(param_index))
85    }
86
87    /// Append a tenant-scoped `AND` clause to the given SQL query.
88    ///
89    /// Use this when the query already has a `WHERE` clause.
90    #[must_use]
91    pub fn apply_and(&self, query: &str, param_index: NonZeroUsize) -> String {
92        format!("{query} AND {}", self.where_clause(param_index))
93    }
94}
95
96pub(crate) fn validate_identifier_path(identifier: &str) -> rskit_errors::AppResult<()> {
97    if identifier.is_empty() || identifier.len() > 128 {
98        return Err(rskit_errors::AppError::invalid_input(
99            "sql.identifier",
100            "SQL identifier must be 1..=128 bytes",
101        ));
102    }
103    if identifier.split('.').all(is_valid_identifier_segment) {
104        Ok(())
105    } else {
106        Err(rskit_errors::AppError::invalid_input(
107            "sql.identifier",
108            "SQL identifier must contain only dotted ASCII identifier segments",
109        ))
110    }
111}
112
113fn is_valid_identifier_segment(segment: &str) -> bool {
114    let mut chars = segment.chars();
115    let Some(first) = chars.next() else {
116        return false;
117    };
118    (first == '_' || first.is_ascii_alphabetic())
119        && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    fn param(index: usize) -> NonZeroUsize {
127        NonZeroUsize::new(index).unwrap()
128    }
129
130    #[test]
131    fn where_clause_basic() {
132        let scope = TenantScope::new("workspace_id", "ws-123").unwrap();
133        assert_eq!(scope.where_clause(param(1)), "workspace_id = $1");
134    }
135
136    #[test]
137    fn where_clause_higher_index() {
138        let scope = TenantScope::new("tenant_id", "t-456").unwrap();
139        assert_eq!(scope.where_clause(param(3)), "tenant_id = $3");
140    }
141
142    #[test]
143    fn zero_parameter_index_is_not_representable() {
144        assert!(NonZeroUsize::new(0).is_none());
145    }
146
147    #[test]
148    fn value_accessor() {
149        let scope = TenantScope::new("workspace_id", "ws-abc").unwrap();
150        assert_eq!(scope.value(), "ws-abc");
151    }
152
153    #[test]
154    fn column_accessor() {
155        let scope = TenantScope::new("org_id", "org-1").unwrap();
156        assert_eq!(scope.column(), "org_id");
157    }
158
159    #[test]
160    fn apply_where() {
161        let scope = TenantScope::new("workspace_id", "ws-1").unwrap();
162        let sql = scope.apply("SELECT * FROM tasks", param(1));
163        assert_eq!(sql, "SELECT * FROM tasks WHERE workspace_id = $1");
164    }
165
166    #[test]
167    fn apply_and() {
168        let scope = TenantScope::new("workspace_id", "ws-1").unwrap();
169        let sql = scope.apply_and("SELECT * FROM tasks WHERE status = $1", param(2));
170        assert_eq!(
171            sql,
172            "SELECT * FROM tasks WHERE status = $1 AND workspace_id = $2"
173        );
174    }
175
176    #[test]
177    fn rejects_empty_value() {
178        assert!(TenantScope::new("workspace_id", "").is_err());
179    }
180
181    #[test]
182    fn dotted_identifier_column() {
183        let scope = TenantScope::new("my_schema.workspace_id", "ws-1").unwrap();
184        assert_eq!(scope.where_clause(param(1)), "my_schema.workspace_id = $1");
185    }
186
187    #[test]
188    fn rejects_unsafe_column_identifiers() {
189        for column in [
190            "",
191            "1tenant_id",
192            "tenant id",
193            "tenant_id; DROP TABLE users",
194            "tenant_id--",
195            "\"tenant_id\"",
196            ".tenant_id",
197            "tenant_id.",
198            "tenant..id",
199        ] {
200            assert!(TenantScope::new(column, "tenant-1").is_err(), "{column}");
201        }
202    }
203
204    #[test]
205    fn tenant_value_is_bound_data_not_identifier() {
206        let scope = TenantScope::new("workspace_id", "ws-1' OR '1'='1").unwrap();
207        assert_eq!(scope.where_clause(param(2)), "workspace_id = $2");
208        assert_eq!(scope.value(), "ws-1' OR '1'='1");
209    }
210
211    #[test]
212    fn clone_preserves_values() {
213        let scope = TenantScope::new("workspace_id", "ws-clone").unwrap();
214        let cloned = scope.clone();
215        assert_eq!(cloned.column(), scope.column());
216        assert_eq!(cloned.value(), scope.value());
217    }
218
219    #[test]
220    fn debug_format() {
221        let scope = TenantScope::new("workspace_id", "ws-1").unwrap();
222        let debug = format!("{scope:?}");
223        assert!(debug.contains("workspace_id"));
224        assert!(debug.contains("ws-1"));
225    }
226}