Skip to main content

corium_authz/
model.rs

1//! The policy vocabulary: object references, relationship tuples, permission
2//! maps, rewrite rules, and view bindings.
3//!
4//! Every name here is *data* read out of the authz database, never a Rust
5//! enum: a deployment invents its own relations (`owner`, `writer`, `viewer`,
6//! `member`, `parent`, `impersonator`) and the evaluator only knows how to
7//! walk them. The one fixed vocabulary is the coarse action classes of
8//! [`corium_protocol::authz::Action`], which are the API contract.
9
10use std::fmt;
11
12use corium_protocol::authz::{Action, ActionClass};
13
14/// Wildcard id (and object type) — `database:*` is "every database".
15pub const WILDCARD: &str = "*";
16
17/// A subject or protected object: a `type` and an `id`, written `type:id`.
18///
19/// Both halves may be the wildcard `*`. The type is everything before the
20/// first `:`, so ids may themselves contain colons (`user:oidc:alice`).
21#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
22pub struct ObjectRef {
23    /// Object type, e.g. `database`, `group`, `tenant`, `user`.
24    pub kind: String,
25    /// Id within the type, or [`WILDCARD`].
26    pub id: String,
27}
28
29impl ObjectRef {
30    /// Builds a reference from its parts.
31    pub fn new(kind: impl Into<String>, id: impl Into<String>) -> Self {
32        Self {
33            kind: kind.into(),
34            id: id.into(),
35        }
36    }
37
38    /// Parses `type:id`. A string with no `:` is read as an id of type
39    /// `user`, the common case in hand-written policy.
40    #[must_use]
41    pub fn parse(text: &str) -> Self {
42        match text.split_once(':') {
43            Some((kind, id)) if !kind.is_empty() && !id.is_empty() => Self::new(kind, id),
44            _ => Self::new("user", text),
45        }
46    }
47
48    /// Every object of this reference's type: `type:*`.
49    #[must_use]
50    pub fn wildcard_of_type(&self) -> Self {
51        Self::new(self.kind.clone(), WILDCARD)
52    }
53
54    /// Whether this reference names every object of its type.
55    #[must_use]
56    pub fn is_wildcard(&self) -> bool {
57        self.id == WILDCARD
58    }
59}
60
61impl fmt::Display for ObjectRef {
62    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
63        write!(formatter, "{}:{}", self.kind, self.id)
64    }
65}
66
67/// The subject half of a tuple: either a concrete object, or the *userset*
68/// `object#relation` — "everyone who holds `relation` on `object`".
69///
70/// The userset form is what makes group nesting explicit
71/// (`group:eng#member writer database:music`); the plain form is expanded
72/// through [`crate::AuthzConfig::expand_relations`] so the shorter, more
73/// common spelling (`group:eng writer database:music`) also works.
74#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
75pub struct SubjectRef {
76    /// The object named on the subject side.
77    pub object: ObjectRef,
78    /// Relation on that object, when the subject is a userset.
79    pub relation: Option<String>,
80}
81
82impl SubjectRef {
83    /// Parses `type:id` or `type:id#relation`.
84    #[must_use]
85    pub fn parse(text: &str) -> Self {
86        match text.split_once('#') {
87            Some((object, relation)) if !relation.is_empty() => Self {
88                object: ObjectRef::parse(object),
89                relation: Some(relation.to_owned()),
90            },
91            _ => Self {
92                object: ObjectRef::parse(text),
93                relation: None,
94            },
95        }
96    }
97}
98
99impl fmt::Display for SubjectRef {
100    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
101        match &self.relation {
102            Some(relation) => write!(formatter, "{}#{relation}", self.object),
103            None => write!(formatter, "{}", self.object),
104        }
105    }
106}
107
108/// A relationship fact: `subject relation object`.
109#[derive(Clone, Debug, PartialEq, Eq)]
110pub struct Tuple {
111    /// Who (or which userset) the relation holds for.
112    pub subject: SubjectRef,
113    /// The relation name.
114    pub relation: String,
115    /// The object the relation is held on.
116    pub object: ObjectRef,
117}
118
119/// A derived-relation rule: `relation` holds on an object when `on_relation`
120/// holds on whatever that object's `via_relation` points at.
121///
122/// This is Zanzibar's tuple-to-userset rewrite. With
123/// `{relation: viewer, via: parent, on: viewer}` and the tuples
124/// `tenant:acme parent database:music` and `user:alice viewer tenant:acme`,
125/// alice is a `viewer` of `database:music` without a tuple naming it.
126#[derive(Clone, Debug, PartialEq, Eq)]
127pub struct Rewrite {
128    /// The relation being derived.
129    pub relation: String,
130    /// Relation walked from the object to its parent.
131    pub via_relation: String,
132    /// Relation required on the parent.
133    pub on_relation: String,
134    /// Object type this rule is restricted to, or `None` for any type.
135    pub object_type: Option<String>,
136}
137
138/// Maps a Corium action onto the relations that satisfy it.
139///
140/// `action` matches an exact action name (`query`, `transact`), an action
141/// class (`read`, `write`, `admin`), or [`WILDCARD`]; `object_type` matches an
142/// object type or [`WILDCARD`].
143#[derive(Clone, Debug, PartialEq, Eq)]
144pub struct Permission {
145    /// Object type the permission applies to.
146    pub object_type: String,
147    /// Action name, action class, or wildcard.
148    pub action: String,
149    /// Relations that satisfy it; any one is enough.
150    pub relations: Vec<String>,
151}
152
153/// The kind of visibility restriction a [`ViewDef`] describes.
154#[derive(Clone, Copy, Debug, PartialEq, Eq)]
155pub enum FilterKind {
156    /// Only the named attributes are visible.
157    AttributeAllowlist,
158    /// Every attribute except the named ones is visible.
159    AttributeDenylist,
160}
161
162impl FilterKind {
163    /// Parses the `:authz.view/filter-type` value.
164    #[must_use]
165    pub fn parse(text: &str) -> Option<Self> {
166        match text {
167            "attribute-allowlist" | "allowlist" => Some(Self::AttributeAllowlist),
168            "attribute-denylist" | "denylist" => Some(Self::AttributeDenylist),
169            _ => None,
170        }
171    }
172}
173
174/// A named, reusable [`ViewFilter`](corium_protocol::authz::ViewFilter)
175/// definition.
176#[derive(Clone, Debug, PartialEq, Eq)]
177pub struct ViewDef {
178    /// Name bindings refer to.
179    pub name: String,
180    /// What the filter does.
181    pub kind: FilterKind,
182    /// Attribute idents (e.g. `:person/email`) the filter names.
183    pub attributes: Vec<String>,
184}
185
186/// Attaches a view (or explicit full visibility) to a successful relation on
187/// an object.
188#[derive(Clone, Debug, PartialEq, Eq)]
189pub struct Binding {
190    /// Relation the binding applies to.
191    pub relation: String,
192    /// Object it applies on; `type:*` covers every object of a type.
193    pub object: ObjectRef,
194    /// View name, when the binding restricts visibility.
195    pub view: Option<String>,
196    /// Marks the relation as granting full visibility, widening any filter
197    /// another successful path would otherwise impose.
198    pub unfiltered: bool,
199}
200
201/// A principal registration: binds a subject id to the provider allowed to
202/// vouch for it, and grants roles from policy data.
203#[derive(Clone, Debug, Default, PartialEq, Eq)]
204pub struct PrincipalDef {
205    /// Subject id, matching [`corium_protocol::authz::Principal::subject`].
206    pub id: String,
207    /// Provider that must have vouched for it, or `*`/`None` for any.
208    pub provider: Option<String>,
209    /// Roles this principal holds regardless of what the provider asserted.
210    pub roles: Vec<String>,
211}
212
213/// An object registration: metadata that names a Corium database an object
214/// stands for, so a check on `database:music` can also consider, say,
215/// `tenant:acme` when that tenant is registered against `music`.
216#[derive(Clone, Debug, PartialEq, Eq)]
217pub struct ObjectDef {
218    /// The object.
219    pub object: ObjectRef,
220    /// Corium database this object stands for, when it names one.
221    pub database: Option<String>,
222}
223
224/// The wire name of an [`Action`], used in `:authz.permission/action`.
225#[must_use]
226pub fn action_name(action: Action) -> &'static str {
227    match action {
228        Action::Query => "query",
229        Action::Pull => "pull",
230        Action::Datoms => "datoms",
231        Action::TxRange => "tx-range",
232        Action::Subscribe => "subscribe",
233        Action::Inspect => "inspect",
234        Action::Transact => "transact",
235        Action::CreateDatabase => "create-database",
236        Action::DeleteDatabase => "delete-database",
237        Action::ForkDatabase => "fork-database",
238        Action::ListDatabases => "list-databases",
239        Action::GarbageCollect => "garbage-collect",
240        Action::ManageIndex => "manage-index",
241    }
242}
243
244/// The [`Action`] a wire name denotes, the inverse of [`action_name`]. Used by
245/// `corium authz check`, which takes the action to test on the command line.
246#[must_use]
247pub fn action_from_name(name: &str) -> Option<Action> {
248    const ACTIONS: [Action; 13] = [
249        Action::Query,
250        Action::Pull,
251        Action::Datoms,
252        Action::TxRange,
253        Action::Subscribe,
254        Action::Inspect,
255        Action::Transact,
256        Action::CreateDatabase,
257        Action::DeleteDatabase,
258        Action::ForkDatabase,
259        Action::ListDatabases,
260        Action::GarbageCollect,
261        Action::ManageIndex,
262    ];
263    ACTIONS
264        .into_iter()
265        .find(|action| action_name(*action) == name)
266}
267
268/// Every action name, for help text and error messages.
269#[must_use]
270pub fn action_names() -> Vec<&'static str> {
271    [
272        Action::Query,
273        Action::Pull,
274        Action::Datoms,
275        Action::TxRange,
276        Action::Subscribe,
277        Action::Inspect,
278        Action::Transact,
279        Action::CreateDatabase,
280        Action::DeleteDatabase,
281        Action::ForkDatabase,
282        Action::ListDatabases,
283        Action::GarbageCollect,
284        Action::ManageIndex,
285    ]
286    .into_iter()
287    .map(action_name)
288    .collect()
289}
290
291/// The wire name of an [`ActionClass`], used in `:authz.permission/action`.
292#[must_use]
293pub fn action_class_name(class: ActionClass) -> &'static str {
294    match class {
295        ActionClass::Read => "read",
296        ActionClass::Write => "write",
297        ActionClass::Admin => "admin",
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn object_refs_round_trip() {
307        let reference = ObjectRef::parse("database:music");
308        assert_eq!(reference, ObjectRef::new("database", "music"));
309        assert_eq!(reference.to_string(), "database:music");
310        assert_eq!(
311            reference.wildcard_of_type(),
312            ObjectRef::new("database", "*")
313        );
314        assert!(reference.wildcard_of_type().is_wildcard());
315        // A bare name is a user id, and a colon inside the id is preserved.
316        assert_eq!(ObjectRef::parse("alice"), ObjectRef::new("user", "alice"));
317        assert_eq!(
318            ObjectRef::parse("user:oidc:alice"),
319            ObjectRef::new("user", "oidc:alice")
320        );
321    }
322
323    #[test]
324    fn subject_refs_carry_usersets() {
325        let plain = SubjectRef::parse("group:eng");
326        assert_eq!(plain.relation, None);
327        let userset = SubjectRef::parse("group:eng#member");
328        assert_eq!(userset.object, ObjectRef::new("group", "eng"));
329        assert_eq!(userset.relation.as_deref(), Some("member"));
330        assert_eq!(userset.to_string(), "group:eng#member");
331    }
332
333    #[test]
334    fn action_names_are_stable() {
335        assert_eq!(action_name(Action::Query), "query");
336        assert_eq!(action_name(Action::CreateDatabase), "create-database");
337        assert_eq!(action_class_name(ActionClass::Admin), "admin");
338    }
339
340    #[test]
341    fn action_names_round_trip() {
342        for name in action_names() {
343            let action = action_from_name(name).expect("every listed name parses");
344            assert_eq!(action_name(action), name);
345        }
346        assert_eq!(action_names().len(), 13);
347        assert!(action_from_name("nonsense").is_none());
348    }
349}