1use std::fmt;
16
17use super::policies::{self as iam_policies, Decision, EvalContext, Policy, ResourceRef};
18
19#[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 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#[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#[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#[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
190pub 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}