Skip to main content

uqa_sql/catalog/security/
system_relations.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! System relation ACL defaults, persistence validation and effective privilege rules.
8
9use super::{
10    table::{
11        role_has_privilege, validate_table_security_invariants, TableAclPrivilege,
12        TablePrivilegeCheck,
13    },
14    BoundTableSecurity, TableSecurity,
15};
16use crate::catalog::roles::identity::RoleSubject;
17use crate::{
18    ast::RoleAttribute,
19    catalog::{
20        roles::{RoleDefinition, RoleMembership, RoleMembershipKey},
21        SystemRelation,
22    },
23};
24use std::{collections::BTreeMap, ops::Deref};
25use uqa_core::RelationIdentity;
26
27/// One catalog tuple ACL and its replacement identity. Equal ACL values can still be different committed tuples.
28#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
29pub struct SystemAcl {
30    pub revision: [u8; 16],
31    pub acl: Vec<super::table_binding::BoundTableAclEntry>,
32}
33#[derive(Clone, Debug, Default, PartialEq, Eq)]
34pub struct SystemRelationSecurity {
35    pub table: Option<SystemAcl>,
36    pub columns: BTreeMap<String, SystemAcl>,
37}
38impl SystemRelationSecurity {
39    pub fn security(&self, relation: SystemRelation) -> BoundTableSecurity {
40        let mut security = relation.bootstrap_security();
41        if let Some(table) = &self.table {
42            security.acl = Some(table.acl.clone());
43        }
44        security.column_acls = self
45            .columns
46            .iter()
47            .filter(|(_, value)| !value.acl.is_empty())
48            .map(|(name, value)| (name.clone(), value.acl.clone()))
49            .collect();
50        security
51    }
52    pub fn entry(&self, column: Option<&str>) -> Option<&SystemAcl> {
53        column.map_or_else(|| self.table.as_ref(), |column| self.columns.get(column))
54    }
55}
56pub type SystemRelationSecurities = BTreeMap<RelationIdentity, SystemRelationSecurity>;
57pub type SystemRelationSecurityRead<'a> = Box<dyn Deref<Target = SystemRelationSecurities> + 'a>;
58
59pub trait SystemRelationSecurityCatalog {
60    fn system_relation_securities(&self) -> SystemRelationSecurityRead<'_>;
61    fn system_relation_security(&self, relation: SystemRelation) -> BoundTableSecurity {
62        security(&self.system_relation_securities(), relation)
63    }
64}
65
66pub fn security(
67    securities: &SystemRelationSecurities,
68    relation: SystemRelation,
69) -> BoundTableSecurity {
70    securities
71        .get(&RelationIdentity::new(
72            relation.namespace(),
73            relation.name(),
74        ))
75        .map(|entry| entry.security(relation))
76        .unwrap_or_else(|| relation.bootstrap_security())
77}
78
79pub const METADATA_PREFIX: &str = "uqa.system_relation_security.v1:";
80
81pub fn metadata_key(relation: SystemRelation, column: Option<&str>) -> String {
82    // Built-in attribute names are immutable; length and quoting never depend on a search path.
83    format!(
84        "{METADATA_PREFIX}{}:{}",
85        relation.qualified_name(),
86        column.unwrap_or("")
87    )
88}
89
90pub fn validate_security(
91    relation: SystemRelation,
92    security: &TableSecurity,
93    roles: &BTreeMap<String, RoleDefinition>,
94) -> Result<(), String> {
95    if super::role_bindings::bind_role(roles, &security.role_owner, "system relation")?
96        != crate::catalog::roles::RoleIdentity::BOOTSTRAP
97    {
98        return Err(format!(
99            "system relation `{}` has an invalid owner",
100            relation.qualified_name()
101        ));
102    }
103    validate_table_security_invariants(security, Some(&relation.column_names()), roles)
104}
105
106fn masks_table_write(
107    relation: SystemRelation,
108    subject: &(impl RoleSubject + ?Sized),
109    check: TablePrivilegeCheck,
110    roles: &BTreeMap<String, RoleDefinition>,
111) -> bool {
112    relation.namespace() == "pg_catalog"
113        && relation.kind() == "table"
114        && !check.grant_option
115        && matches!(
116            check.privilege,
117            TableAclPrivilege::Insert
118                | TableAclPrivilege::Update
119                | TableAclPrivilege::Delete
120                | TableAclPrivilege::Truncate
121        )
122        && !subject
123            .role_definition(roles)
124            .is_some_and(|role| role.has(RoleAttribute::Superuser))
125}
126
127pub fn has_table_privilege(
128    relation: SystemRelation,
129    security: &TableSecurity,
130    subject: &(impl RoleSubject + ?Sized),
131    check: TablePrivilegeCheck,
132    roles: &BTreeMap<String, RoleDefinition>,
133    memberships: &BTreeMap<RoleMembershipKey, RoleMembership>,
134) -> bool {
135    !masks_table_write(relation, subject, check, roles)
136        && role_has_privilege(security, subject, check, roles, memberships)
137}
138
139pub fn has_column_privilege(
140    relation: SystemRelation,
141    security: &TableSecurity,
142    column: &str,
143    subject: &(impl RoleSubject + ?Sized),
144    check: TablePrivilegeCheck,
145    roles: &BTreeMap<String, RoleDefinition>,
146    memberships: &BTreeMap<RoleMembershipKey, RoleMembership>,
147) -> bool {
148    if !masks_table_write(relation, subject, check, roles) {
149        return super::columns::role_has_column_privilege(
150            security,
151            column,
152            subject,
153            check,
154            roles,
155            memberships,
156        );
157    }
158    // The system-table write mask applies to relation ACLs. Explicit attribute grants remain visible to column privilege inquiry.
159    security.column_acls.get(column).is_some_and(|acl| {
160        acl.iter().any(|entry| {
161            entry.privileges.intersects(check.privilege.mask())
162                && (entry.role.is_public()
163                    || crate::catalog::roles::role_inherits(
164                        roles,
165                        memberships,
166                        subject,
167                        &entry.role,
168                    ))
169        })
170    })
171}
172
173#[cfg(test)]
174mod tests;