Skip to main content

qail_core/access/
model.rs

1use std::collections::BTreeSet;
2
3use crate::ast::Action;
4use crate::rls::SuperAdminToken;
5
6use super::ident::normalize_column_name;
7
8/// High-level data operation governed by access policy.
9#[derive(
10    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
11)]
12#[serde(rename_all = "lowercase")]
13pub enum AccessOperation {
14    /// Read rows or vector points.
15    Read,
16    /// Create rows or vector points.
17    Create,
18    /// Update existing rows or vector points.
19    Update,
20    /// Delete rows or vector points.
21    Delete,
22}
23
24impl AccessOperation {
25    /// Conservative operation mapping for non-MERGE commands.
26    pub fn required_for_action(action: Action) -> Option<&'static [Self]> {
27        match action {
28            Action::Get
29            | Action::Cnt
30            | Action::Export
31            | Action::With
32            | Action::Search
33            | Action::Scroll => Some(&[Self::Read]),
34            Action::Add => Some(&[Self::Create]),
35            Action::Set | Action::Put | Action::Over => Some(&[Self::Update]),
36            Action::Upsert => Some(&[Self::Create, Self::Update]),
37            Action::Del => Some(&[Self::Delete]),
38            _ => None,
39        }
40    }
41}
42
43/// The subject being checked against an access policy.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct AccessContext {
46    /// Authenticated user or service principal ID.
47    pub subject_id: Option<String>,
48    /// Tenant carried with the subject, if any.
49    pub tenant_id: Option<String>,
50    /// Subject roles.
51    pub roles: BTreeSet<String>,
52    /// Subject scopes or permissions.
53    pub scopes: BTreeSet<String>,
54    bypass: bool,
55}
56
57impl AccessContext {
58    /// Anonymous context with no roles, scopes, tenant, or bypass.
59    pub fn anonymous() -> Self {
60        Self {
61            subject_id: None,
62            tenant_id: None,
63            roles: BTreeSet::new(),
64            scopes: BTreeSet::new(),
65            bypass: false,
66        }
67    }
68
69    /// Authenticated context for a subject ID.
70    pub fn subject(subject_id: impl Into<String>) -> Self {
71        Self {
72            subject_id: Some(subject_id.into()),
73            ..Self::anonymous()
74        }
75    }
76
77    /// Super-admin context. The token cannot be fabricated outside `qail-core`.
78    pub fn super_admin(_token: SuperAdminToken) -> Self {
79        Self {
80            bypass: true,
81            ..Self::anonymous()
82        }
83    }
84
85    /// Attach a tenant ID.
86    pub fn with_tenant(mut self, tenant_id: impl Into<String>) -> Self {
87        self.tenant_id = Some(tenant_id.into());
88        self
89    }
90
91    /// Attach one role.
92    pub fn with_role(mut self, role: impl Into<String>) -> Self {
93        self.roles.insert(role.into());
94        self
95    }
96
97    /// Attach many roles.
98    pub fn with_roles<I, S>(mut self, roles: I) -> Self
99    where
100        I: IntoIterator<Item = S>,
101        S: Into<String>,
102    {
103        self.roles.extend(roles.into_iter().map(Into::into));
104        self
105    }
106
107    /// Attach one scope.
108    pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
109        self.scopes.insert(scope.into());
110        self
111    }
112
113    /// Attach many scopes.
114    pub fn with_scopes<I, S>(mut self, scopes: I) -> Self
115    where
116        I: IntoIterator<Item = S>,
117        S: Into<String>,
118    {
119        self.scopes.extend(scopes.into_iter().map(Into::into));
120        self
121    }
122
123    /// Returns true when this context bypasses vertical checks.
124    pub fn bypasses_access(&self) -> bool {
125        self.bypass
126    }
127
128    pub(super) fn has_any_role(&self, required: &BTreeSet<String>) -> bool {
129        required.is_empty() || required.iter().any(|role| self.roles.contains(role))
130    }
131
132    pub(super) fn has_all_scopes(&self, required: &BTreeSet<String>) -> bool {
133        required.is_subset(&self.scopes)
134    }
135}
136
137impl Default for AccessContext {
138    fn default() -> Self {
139        Self::anonymous()
140    }
141}
142
143/// Default decision when no table policy matches.
144#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
145#[serde(rename_all = "lowercase")]
146pub enum AccessDecision {
147    /// Allow when no table policy matches.
148    Allow,
149    /// Deny when no table policy matches.
150    Deny,
151}
152
153/// Column access rule for reads, writes, or RETURNING clauses.
154#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
155#[serde(rename_all = "snake_case")]
156pub enum ColumnRule {
157    /// Any column is allowed.
158    #[default]
159    Any,
160    /// No columns are allowed.
161    DenyAll,
162    /// Only the listed columns are allowed.
163    Only(BTreeSet<String>),
164    /// Any column except the listed columns is allowed.
165    Except(BTreeSet<String>),
166}
167
168impl ColumnRule {
169    /// Create an allow-list rule.
170    pub fn only<I, S>(columns: I) -> Self
171    where
172        I: IntoIterator<Item = S>,
173        S: Into<String>,
174    {
175        Self::Only(columns.into_iter().map(normalize_column_name).collect())
176    }
177
178    /// Create a deny-list rule.
179    pub fn except<I, S>(columns: I) -> Self
180    where
181        I: IntoIterator<Item = S>,
182        S: Into<String>,
183    {
184        Self::Except(columns.into_iter().map(normalize_column_name).collect())
185    }
186
187    /// Returns true if this rule constrains column access.
188    pub fn is_restrictive(&self) -> bool {
189        !matches!(self, Self::Any)
190    }
191
192    /// Returns true if `column` is allowed by this rule.
193    pub fn allows(&self, column: &str) -> bool {
194        let normalized = normalize_column_name(column);
195        match self {
196            Self::Any => true,
197            Self::DenyAll => false,
198            Self::Only(columns) => columns.contains(&normalized),
199            Self::Except(columns) => !columns.contains(&normalized),
200        }
201    }
202}
203
204/// Access rule for one table.
205#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
206pub struct TableAccessPolicy {
207    /// Allowed operations.
208    #[serde(default)]
209    pub operations: BTreeSet<AccessOperation>,
210    /// Explicitly denied operations.
211    #[serde(default)]
212    pub denied_operations: BTreeSet<AccessOperation>,
213    /// Read projection rule.
214    #[serde(default)]
215    pub read_columns: ColumnRule,
216    /// Write payload rule.
217    #[serde(default)]
218    pub write_columns: ColumnRule,
219    /// RETURNING projection rule. Enforced together with `read_columns`.
220    #[serde(default)]
221    pub returning_columns: ColumnRule,
222    /// At least one of these roles is required. Empty means no role gate.
223    #[serde(default)]
224    pub require_any_role: BTreeSet<String>,
225    /// All listed scopes are required. Empty means no scope gate.
226    #[serde(default)]
227    pub require_scopes: BTreeSet<String>,
228}
229
230impl TableAccessPolicy {
231    /// Empty table policy: no operations allowed until added.
232    pub fn new() -> Self {
233        Self::default()
234    }
235
236    /// Allow the listed operations.
237    pub fn allow_operations<I>(mut self, operations: I) -> Self
238    where
239        I: IntoIterator<Item = AccessOperation>,
240    {
241        self.operations.extend(operations);
242        self
243    }
244
245    /// Deny the listed operations even if otherwise allowed.
246    pub fn deny_operations<I>(mut self, operations: I) -> Self
247    where
248        I: IntoIterator<Item = AccessOperation>,
249    {
250        self.denied_operations.extend(operations);
251        self
252    }
253
254    /// Restrict read projection columns.
255    pub fn read_columns(mut self, rule: ColumnRule) -> Self {
256        self.read_columns = rule;
257        self
258    }
259
260    /// Restrict write payload columns.
261    pub fn write_columns(mut self, rule: ColumnRule) -> Self {
262        self.write_columns = rule;
263        self
264    }
265
266    /// Restrict RETURNING columns.
267    pub fn returning_columns(mut self, rule: ColumnRule) -> Self {
268        self.returning_columns = rule;
269        self
270    }
271
272    /// Require at least one role.
273    pub fn require_any_role<I, S>(mut self, roles: I) -> Self
274    where
275        I: IntoIterator<Item = S>,
276        S: Into<String>,
277    {
278        self.require_any_role
279            .extend(roles.into_iter().map(Into::into));
280        self
281    }
282
283    /// Require all scopes.
284    pub fn require_scopes<I, S>(mut self, scopes: I) -> Self
285    where
286        I: IntoIterator<Item = S>,
287        S: Into<String>,
288    {
289        self.require_scopes
290            .extend(scopes.into_iter().map(Into::into));
291        self
292    }
293
294    pub(super) fn allows_operation(&self, operation: AccessOperation) -> bool {
295        self.operations.contains(&operation) && !self.denied_operations.contains(&operation)
296    }
297}
298
299impl Default for TableAccessPolicy {
300    fn default() -> Self {
301        Self {
302            operations: BTreeSet::new(),
303            denied_operations: BTreeSet::new(),
304            read_columns: ColumnRule::Any,
305            write_columns: ColumnRule::Any,
306            returning_columns: ColumnRule::Any,
307            require_any_role: BTreeSet::new(),
308            require_scopes: BTreeSet::new(),
309        }
310    }
311}