Skip to main content

corium_authz/
bootstrap.rs

1//! Building and editing an authorization database.
2//!
3//! Two jobs live here. The first is turning policy text into a database value
4//! without a transactor: [`PolicyBuilder`] installs the reserved schema and
5//! applies transaction forms in process, which is what tests, `authz check
6//! --policy`, and embedded deployments use. The second is emitting the
7//! transaction forms an operator's `authz grant` / `authz revoke` sends to a
8//! real authz database, so both paths speak exactly the same EDN.
9
10use corium_core::{EntityId, Keyword, KeywordInterner, Partition, Value};
11use corium_db::{Db, FIRST_USER_ID};
12use corium_forms::schemaform::schema_from_edn;
13use corium_forms::txforms::tx_items_from_edn;
14use corium_query::edn::{Edn, read_all};
15
16use crate::model::{ObjectRef, SubjectRef};
17use crate::schema;
18
19/// Failure to build or edit an authz database in process.
20#[derive(Debug, thiserror::Error)]
21pub enum BootstrapError {
22    /// The policy text is not valid EDN.
23    #[error("cannot read policy EDN: {0}")]
24    Edn(#[from] corium_query::edn::EdnError),
25    /// A form is not a valid transaction form.
26    #[error("bad policy form: {0}")]
27    Form(#[from] corium_forms::txforms::TxFormError),
28    /// The transaction does not apply.
29    #[error("cannot apply policy transaction: {0}")]
30    Transact(#[from] corium_tx::TxError),
31}
32
33/// Applies policy transactions to an in-memory authz database.
34pub struct PolicyBuilder {
35    db: Db,
36    interner: KeywordInterner,
37    next_user: u64,
38}
39
40impl Default for PolicyBuilder {
41    fn default() -> Self {
42        Self::new()
43    }
44}
45
46impl PolicyBuilder {
47    /// An empty authz database with the reserved schema installed.
48    ///
49    /// # Panics
50    /// Never: the reserved schema is a crate constant covered by tests.
51    #[must_use]
52    pub fn new() -> Self {
53        let (schema, idents) =
54            schema_from_edn(&schema::schema_forms()).expect("reserved schema is well formed");
55        let interner = KeywordInterner::default();
56        Self {
57            db: Db::new(schema).with_naming(idents, interner.clone()),
58            interner,
59            next_user: FIRST_USER_ID,
60        }
61    }
62
63    /// Applies EDN transaction forms (a vector of maps, or bare forms).
64    ///
65    /// # Errors
66    /// Returns [`BootstrapError`] when the text is malformed or the
67    /// transaction does not apply.
68    pub fn transact_edn(&mut self, text: &str) -> Result<&mut Self, BootstrapError> {
69        let forms = read_all(text)?;
70        let forms = match forms.as_slice() {
71            [Edn::Vector(items) | Edn::List(items)] => items.clone(),
72            _ => forms,
73        };
74        self.transact(&forms)
75    }
76
77    /// Applies transaction forms.
78    ///
79    /// # Errors
80    /// Returns [`BootstrapError`] when a form is malformed or the transaction
81    /// does not apply.
82    pub fn transact(&mut self, forms: &[Edn]) -> Result<&mut Self, BootstrapError> {
83        let items = tx_items_from_edn(&self.db, &mut self.interner, forms)?;
84        let t = self.db.basis_t() + 1;
85        let tx = EntityId::new(Partition::Tx as u32, t);
86        let prepared = corium_tx::prepare(&self.db, items, tx, self.next_user)?;
87        self.next_user = prepared
88            .tempids
89            .values()
90            .filter(|entity| entity.partition() == Partition::User as u32)
91            .map(|entity| entity.sequence() + 1)
92            .max()
93            .unwrap_or(self.next_user)
94            .max(self.next_user);
95        self.db = self
96            .db
97            .clone()
98            .with_naming(self.db.idents().clone(), self.interner.clone())
99            .with_transaction(t, &prepared.datoms);
100        Ok(self)
101    }
102
103    /// The current database value.
104    #[must_use]
105    pub fn db(&self) -> Db {
106        self.db.clone()
107    }
108}
109
110/// Builds an authz database from policy EDN.
111///
112/// # Errors
113/// Returns [`BootstrapError`] when the text is malformed or does not apply.
114pub fn policy_db(text: &str) -> Result<Db, BootstrapError> {
115    let mut builder = PolicyBuilder::new();
116    builder.transact_edn(text)?;
117    Ok(builder.db())
118}
119
120/// A transaction form asserting the tuple `subject relation object`.
121#[must_use]
122pub fn tuple_form(subject: &str, relation: &str, object: &str) -> Edn {
123    // Round-tripping through the parsers normalizes what an operator typed
124    // (`alice` becomes `user:alice`) so the stored tuple matches what a check
125    // derives from a principal.
126    entity(
127        "tuple",
128        &[
129            (
130                schema::TUPLE_SUBJECT,
131                SubjectRef::parse(subject).to_string(),
132            ),
133            (schema::TUPLE_RELATION, relation.to_owned()),
134            (schema::TUPLE_OBJECT, ObjectRef::parse(object).to_string()),
135        ],
136    )
137}
138
139/// A transaction form registering a principal.
140#[must_use]
141pub fn principal_form(id: &str, provider: Option<&str>, roles: &[String]) -> Edn {
142    let mut pairs = vec![(Edn::keyword(schema::PRINCIPAL_ID), Edn::Str(id.to_owned()))];
143    if let Some(provider) = provider {
144        pairs.push((
145            Edn::keyword(schema::PRINCIPAL_PROVIDER),
146            Edn::Str(provider.to_owned()),
147        ));
148    }
149    if !roles.is_empty() {
150        pairs.push((
151            Edn::keyword(schema::PRINCIPAL_ROLE),
152            Edn::Vector(roles.iter().cloned().map(Edn::Str).collect()),
153        ));
154    }
155    map_with_id(format!("principal-{id}"), pairs)
156}
157
158/// A transaction form mapping an action (or action class) onto relations.
159#[must_use]
160pub fn permission_form(object_type: &str, action: &str, relations: &[String]) -> Edn {
161    map_with_id(
162        format!("permission-{object_type}-{action}"),
163        vec![
164            (
165                Edn::keyword(schema::PERMISSION_OBJECT_TYPE),
166                Edn::Str(object_type.to_owned()),
167            ),
168            (
169                Edn::keyword(schema::PERMISSION_ACTION),
170                Edn::Str(action.to_owned()),
171            ),
172            (
173                Edn::keyword(schema::PERMISSION_RELATION),
174                Edn::Vector(relations.iter().cloned().map(Edn::Str).collect()),
175            ),
176        ],
177    )
178}
179
180/// The entity id of the tuple `subject relation object`, when the database
181/// holds it. `authz revoke` retracts what this finds.
182#[must_use]
183pub fn find_tuple(db: &Db, subject: &str, relation: &str, object: &str) -> Option<EntityId> {
184    let subject = SubjectRef::parse(subject).to_string();
185    let object = ObjectRef::parse(object).to_string();
186    let attribute = |ident: &str| db.idents().entid(&Keyword::parse(ident));
187    let (subject_attr, relation_attr, object_attr) = (
188        attribute(schema::TUPLE_SUBJECT)?,
189        attribute(schema::TUPLE_RELATION)?,
190        attribute(schema::TUPLE_OBJECT)?,
191    );
192    let matches = |entity: EntityId, attribute, expected: &str| {
193        db.values(entity, attribute)
194            .iter()
195            .any(|value| matches!(value, Value::Str(text) if &**text == expected))
196    };
197    db.datoms_for_attribute(subject_attr)
198        .filter(|datom| matches!(&datom.v, Value::Str(text) if **text == subject))
199        .map(|datom| datom.e)
200        .find(|entity| {
201            matches(*entity, relation_attr, relation) && matches(*entity, object_attr, &object)
202        })
203}
204
205/// A `[:db/retractEntity …]` form.
206#[must_use]
207pub fn retract_entity_form(entity: EntityId) -> Edn {
208    Edn::Vector(vec![
209        Edn::keyword("db/retractEntity"),
210        Edn::Long(i64::try_from(entity.raw()).unwrap_or_default()),
211    ])
212}
213
214fn entity(prefix: &str, attributes: &[(&str, String)]) -> Edn {
215    let pairs = attributes
216        .iter()
217        .map(|(attribute, value)| (Edn::keyword(attribute), Edn::Str(value.clone())))
218        .collect();
219    let id = format!(
220        "{prefix}-{}",
221        attributes
222            .iter()
223            .map(|(_, value)| value.as_str())
224            .collect::<Vec<_>>()
225            .join("-")
226    );
227    map_with_id(id, pairs)
228}
229
230fn map_with_id(temp_id: String, mut pairs: Vec<(Edn, Edn)>) -> Edn {
231    pairs.push((Edn::keyword("db/id"), Edn::Str(temp_id)));
232    pairs.sort();
233    Edn::Map(pairs)
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::policy::Policy;
240
241    #[test]
242    fn builds_and_edits_a_policy_database() {
243        let mut builder = PolicyBuilder::new();
244        builder
245            .transact(&[
246                tuple_form("alice", "writer", "database:music"),
247                permission_form("database", "write", &["writer".to_owned()]),
248            ])
249            .expect("policy applies");
250        let db = builder.db();
251        let policy = Policy::compile(&db).expect("policy compiles");
252        assert_eq!(policy.stats().tuples, 1);
253        assert_eq!(policy.stats().permissions, 1);
254
255        let entity = find_tuple(&db, "alice", "writer", "database:music").expect("tuple is stored");
256        builder
257            .transact(&[retract_entity_form(entity)])
258            .expect("retraction applies");
259        let policy = Policy::compile(&builder.db()).expect("policy compiles");
260        assert_eq!(policy.stats().tuples, 0);
261    }
262
263    #[test]
264    fn normalizes_operator_shorthand() {
265        let Edn::Map(pairs) = tuple_form("alice", "member", "group:eng") else {
266            panic!("tuple form is a map");
267        };
268        let subject = pairs
269            .iter()
270            .find(|(key, _)| *key == Edn::keyword(schema::TUPLE_SUBJECT))
271            .map(|(_, value)| value.clone());
272        assert_eq!(subject, Some(Edn::Str("user:alice".to_owned())));
273    }
274}