Skip to main content

reddb_server/auth/
column_policy_gate.rs

1//! Column-level IAM policy gate.
2//!
3//! This module is the narrow enforcement interface for column resources
4//! (`column:[schema.]table.column`). It deliberately sits on top of the
5//! existing IAM policy kernel instead of adding a second policy language.
6//!
7//! Semantics:
8//! - table access is checked first and is required;
9//! - explicit column `deny` rejects that projected column;
10//! - explicit column `allow` permits that projected column;
11//! - column `default-deny` inherits an allowed table decision so existing
12//!   table-only policies keep working until callers opt into precise
13//!   projection checks.
14
15use std::fmt;
16
17use super::policies::{self as iam_policies, Decision, EvalContext, Policy, ResourceRef};
18
19/// One resolved table column requested by a query path.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct ColumnRef {
22    pub schema: Option<String>,
23    pub table: String,
24    pub column: String,
25}
26
27impl ColumnRef {
28    pub fn new(table: impl Into<String>, column: impl Into<String>) -> Self {
29        Self {
30            schema: None,
31            table: table.into(),
32            column: column.into(),
33        }
34    }
35
36    pub fn with_schema(
37        schema: impl Into<String>,
38        table: impl Into<String>,
39        column: impl Into<String>,
40    ) -> Self {
41        Self {
42            schema: Some(schema.into()),
43            table: table.into(),
44            column: column.into(),
45        }
46    }
47
48    /// Parse the documented column resource name shape:
49    /// `[schema.]table.column`. JSON paths are intentionally not accepted.
50    pub fn parse_resource_name(name: &str) -> Result<Self, ColumnPolicyError> {
51        let parts: Vec<&str> = name.split('.').collect();
52        match parts.as_slice() {
53            [table, column] if valid_part(table) && valid_part(column) => {
54                Ok(Self::new(*table, *column))
55            }
56            [schema, table, column]
57                if valid_part(schema) && valid_part(table) && valid_part(column) =>
58            {
59                Ok(Self::with_schema(*schema, *table, *column))
60            }
61            _ => Err(ColumnPolicyError::InvalidColumnResource(name.to_string())),
62        }
63    }
64
65    pub fn table_resource_name(&self) -> String {
66        match &self.schema {
67            Some(schema) => format!("{schema}.{}", self.table),
68            None => self.table.clone(),
69        }
70    }
71
72    pub fn column_resource_name(&self) -> String {
73        format!("{}.{}", self.table_resource_name(), self.column)
74    }
75}
76
77/// A set of resolved columns from one table-like source.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct ColumnAccessRequest {
80    pub action: String,
81    pub schema: Option<String>,
82    pub table: String,
83    pub columns: Vec<String>,
84}
85
86impl ColumnAccessRequest {
87    pub fn select(
88        table: impl Into<String>,
89        columns: impl IntoIterator<Item = impl Into<String>>,
90    ) -> Self {
91        Self {
92            action: "select".to_string(),
93            schema: None,
94            table: table.into(),
95            columns: columns.into_iter().map(Into::into).collect(),
96        }
97    }
98
99    pub fn update(
100        table: impl Into<String>,
101        columns: impl IntoIterator<Item = impl Into<String>>,
102    ) -> Self {
103        Self {
104            action: "update".to_string(),
105            schema: None,
106            table: table.into(),
107            columns: columns.into_iter().map(Into::into).collect(),
108        }
109    }
110
111    pub fn with_schema(mut self, schema: impl Into<String>) -> Self {
112        self.schema = Some(schema.into());
113        self
114    }
115
116    fn table_resource_name(&self) -> String {
117        match &self.schema {
118            Some(schema) => format!("{schema}.{}", self.table),
119            None => self.table.clone(),
120        }
121    }
122
123    fn column_ref(&self, column: &str) -> ColumnRef {
124        ColumnRef {
125            schema: self.schema.clone(),
126            table: self.table.clone(),
127            column: column.to_string(),
128        }
129    }
130}
131
132/// Per-column decision after table inheritance is applied.
133#[derive(Debug, Clone, PartialEq)]
134pub struct ColumnDecision {
135    pub column: String,
136    pub resource: ResourceRef,
137    pub raw_decision: Decision,
138    pub effective: ColumnDecisionEffect,
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum ColumnDecisionEffect {
143    Allowed,
144    Denied,
145    InheritedTableAllow,
146}
147
148/// Full gate result for one projected table source.
149#[derive(Debug, Clone, PartialEq)]
150pub struct ColumnPolicyOutcome {
151    pub table_resource: ResourceRef,
152    pub table_decision: Decision,
153    pub columns: Vec<ColumnDecision>,
154}
155
156impl ColumnPolicyOutcome {
157    pub fn allowed(&self) -> bool {
158        table_decision_allows(&self.table_decision)
159            && self
160                .columns
161                .iter()
162                .all(|c| c.effective != ColumnDecisionEffect::Denied)
163    }
164
165    pub fn first_denied_column(&self) -> Option<&ColumnDecision> {
166        self.columns
167            .iter()
168            .find(|c| c.effective == ColumnDecisionEffect::Denied)
169    }
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub enum ColumnPolicyError {
174    InvalidColumnResource(String),
175}
176
177impl fmt::Display for ColumnPolicyError {
178    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179        match self {
180            Self::InvalidColumnResource(name) => write!(
181                f,
182                "invalid column resource `{name}`; expected [schema.]table.column"
183            ),
184        }
185    }
186}
187
188impl std::error::Error for ColumnPolicyError {}
189
190/// Thin evaluator wrapper over effective IAM policies.
191pub struct ColumnPolicyGate<'a> {
192    policies: &'a [&'a Policy],
193}
194
195impl<'a> ColumnPolicyGate<'a> {
196    pub fn new(policies: &'a [&'a Policy]) -> Self {
197        Self { policies }
198    }
199
200    pub fn evaluate(
201        &self,
202        request: &ColumnAccessRequest,
203        ctx: &EvalContext,
204    ) -> ColumnPolicyOutcome {
205        let mut table_resource = ResourceRef::new("table", request.table_resource_name());
206        if let Some(tenant) = ctx.current_tenant.as_deref() {
207            table_resource = table_resource.with_tenant(tenant.to_string());
208        }
209
210        let table_decision =
211            iam_policies::evaluate(self.policies, &request.action, &table_resource, ctx);
212
213        let columns = request
214            .columns
215            .iter()
216            .map(|column| {
217                let column_ref = request.column_ref(column);
218                let mut resource = ResourceRef::new("column", column_ref.column_resource_name());
219                if let Some(tenant) = ctx.current_tenant.as_deref() {
220                    resource = resource.with_tenant(tenant.to_string());
221                }
222                let raw_decision =
223                    iam_policies::evaluate(self.policies, &request.action, &resource, ctx);
224                let effective = effective_column_decision(&table_decision, &raw_decision);
225                ColumnDecision {
226                    column: column.clone(),
227                    resource,
228                    raw_decision,
229                    effective,
230                }
231            })
232            .collect();
233
234        ColumnPolicyOutcome {
235            table_resource,
236            table_decision,
237            columns,
238        }
239    }
240}
241
242fn effective_column_decision(
243    table_decision: &Decision,
244    column_decision: &Decision,
245) -> ColumnDecisionEffect {
246    match column_decision {
247        Decision::Deny { .. } => ColumnDecisionEffect::Denied,
248        Decision::Allow { .. } | Decision::AdminBypass => ColumnDecisionEffect::Allowed,
249        Decision::DefaultDeny if table_decision_allows(table_decision) => {
250            ColumnDecisionEffect::InheritedTableAllow
251        }
252        Decision::DefaultDeny => ColumnDecisionEffect::Denied,
253    }
254}
255
256fn table_decision_allows(decision: &Decision) -> bool {
257    matches!(decision, Decision::Allow { .. } | Decision::AdminBypass)
258}
259
260fn valid_part(s: &str) -> bool {
261    !s.is_empty() && !s.starts_with("tenant/")
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use crate::auth::policies::{compile_action, Effect, ResourcePattern, Statement};
268
269    fn policy(id: &str, effect: Effect, actions: &[&str], resources: &[&str]) -> Policy {
270        let statements = vec![Statement {
271            sid: Some(id.to_string()),
272            effect,
273            actions: actions.iter().map(|a| compile_action(a)).collect(),
274            resources: resources
275                .iter()
276                .map(|r| {
277                    if *r == "*" {
278                        ResourcePattern::Wildcard
279                    } else if r.contains('*') {
280                        ResourcePattern::Glob((*r).to_string())
281                    } else {
282                        let (kind, name) = r.split_once(':').unwrap();
283                        ResourcePattern::Exact {
284                            kind: kind.to_string(),
285                            name: name.to_string(),
286                        }
287                    }
288                })
289                .collect(),
290            condition: None,
291        }];
292        Policy {
293            id: id.to_string(),
294            version: 1,
295            statements,
296            tenant: None,
297            created_at: 0,
298            updated_at: 0,
299        }
300    }
301
302    #[test]
303    fn default_column_decision_inherits_table_allow() {
304        let allow_table = policy("allow_table", Effect::Allow, &["select"], &["table:users"]);
305        let policies = [&allow_table];
306        let gate = ColumnPolicyGate::new(&policies);
307        let request = ColumnAccessRequest::select("users", ["id", "name"]);
308
309        let outcome = gate.evaluate(&request, &EvalContext::default());
310
311        assert!(outcome.allowed());
312        assert_eq!(
313            outcome.columns[0].effective,
314            ColumnDecisionEffect::InheritedTableAllow
315        );
316    }
317
318    #[test]
319    fn explicit_column_deny_overrides_table_allow() {
320        let allow_table = policy("allow_table", Effect::Allow, &["select"], &["table:users"]);
321        let deny_email = policy(
322            "deny_email",
323            Effect::Deny,
324            &["select"],
325            &["column:users.email"],
326        );
327        let policies = [&allow_table, &deny_email];
328        let gate = ColumnPolicyGate::new(&policies);
329        let request = ColumnAccessRequest::select("users", ["id", "email"]);
330
331        let outcome = gate.evaluate(&request, &EvalContext::default());
332
333        assert!(!outcome.allowed());
334        let denied = outcome.first_denied_column().unwrap();
335        assert_eq!(denied.column, "email");
336        assert_eq!(denied.effective, ColumnDecisionEffect::Denied);
337    }
338
339    #[test]
340    fn column_allow_does_not_bypass_missing_table_allow() {
341        let allow_column = policy(
342            "allow_email",
343            Effect::Allow,
344            &["select"],
345            &["column:users.email"],
346        );
347        let policies = [&allow_column];
348        let gate = ColumnPolicyGate::new(&policies);
349        let request = ColumnAccessRequest::select("users", ["email"]);
350
351        let outcome = gate.evaluate(&request, &EvalContext::default());
352
353        assert!(!outcome.allowed());
354        assert!(matches!(outcome.table_decision, Decision::DefaultDeny));
355        assert_eq!(outcome.columns[0].effective, ColumnDecisionEffect::Allowed);
356    }
357
358    #[test]
359    fn tenant_context_uses_existing_policy_resource_matching() {
360        let allow_table = policy(
361            "allow_tenant_table",
362            Effect::Allow,
363            &["select"],
364            &["table:orders"],
365        );
366        let deny_email = policy(
367            "deny_tenant_email",
368            Effect::Deny,
369            &["select"],
370            &["column:orders.email"],
371        );
372        let policies = [&allow_table, &deny_email];
373        let gate = ColumnPolicyGate::new(&policies);
374        let ctx = EvalContext {
375            current_tenant: Some("acme".to_string()),
376            ..EvalContext::default()
377        };
378
379        let outcome = gate.evaluate(&ColumnAccessRequest::select("orders", ["email"]), &ctx);
380
381        assert!(!outcome.allowed());
382        assert_eq!(outcome.table_resource.tenant.as_deref(), Some("acme"));
383        assert_eq!(outcome.columns[0].resource.name, "orders.email".to_string());
384    }
385
386    #[test]
387    fn parses_only_documented_column_resource_shape() {
388        assert_eq!(
389            ColumnRef::parse_resource_name("billing.invoices.total").unwrap(),
390            ColumnRef::with_schema("billing", "invoices", "total")
391        );
392        assert!(ColumnRef::parse_resource_name("users.profile.address.city").is_err());
393        assert!(ColumnRef::parse_resource_name("tenant/acme/users.email").is_err());
394    }
395}