feat(rebac): first pass
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
//! Domain types for the ReBAC authorization model.
|
||||
//!
|
||||
//! These types are storage-agnostic — they describe the relationship between
|
||||
//! a subject (who), a resource (what), and a permission (action). The
|
||||
//! `AuthorizationEngine` port consumes them and the `PgAclEngine` implementation
|
||||
//! maps them to / from `storage.access_grants` rows.
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Subject — who has the permission
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// A principal that can be granted permissions.
|
||||
///
|
||||
/// All variants carry a `Uuid` that uniquely identifies the subject within
|
||||
/// its type's namespace.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
pub enum Subject {
|
||||
/// A registered OxiCloud user (`auth.users.id`).
|
||||
User(Uuid),
|
||||
/// A user group (reserved for future use; no group CRUD in v1).
|
||||
Group(Uuid),
|
||||
/// An anonymous share token (`storage.shares.id`).
|
||||
Token(Uuid),
|
||||
/// A federated identity from another server — Open Cloud Mesh, external
|
||||
/// OIDC, etc. Refers to `auth.external_subjects.id` (future table).
|
||||
External(Uuid),
|
||||
}
|
||||
|
||||
impl Subject {
|
||||
/// SQL discriminator string matching the `subject_type` CHECK constraint.
|
||||
pub fn type_str(&self) -> &'static str {
|
||||
match self {
|
||||
Subject::User(_) => "user",
|
||||
Subject::Group(_) => "group",
|
||||
Subject::Token(_) => "token",
|
||||
Subject::External(_) => "external",
|
||||
}
|
||||
}
|
||||
|
||||
/// The raw UUID regardless of variant.
|
||||
pub fn id(&self) -> Uuid {
|
||||
match self {
|
||||
Subject::User(id) | Subject::Group(id) | Subject::Token(id) | Subject::External(id) => {
|
||||
*id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstruct from a SQL row's `(subject_type, subject_id)` pair.
|
||||
pub fn from_parts(subject_type: &str, id: Uuid) -> Option<Self> {
|
||||
match subject_type {
|
||||
"user" => Some(Subject::User(id)),
|
||||
"group" => Some(Subject::Group(id)),
|
||||
"token" => Some(Subject::Token(id)),
|
||||
"external" => Some(Subject::External(id)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Resource — what the permission is on
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
pub enum Resource {
|
||||
Folder(Uuid),
|
||||
File(Uuid),
|
||||
}
|
||||
|
||||
impl Resource {
|
||||
pub fn type_str(&self) -> &'static str {
|
||||
match self {
|
||||
Resource::Folder(_) => "folder",
|
||||
Resource::File(_) => "file",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> Uuid {
|
||||
match self {
|
||||
Resource::Folder(id) | Resource::File(id) => *id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_parts(resource_type: &str, id: Uuid) -> Option<Self> {
|
||||
match resource_type {
|
||||
"folder" => Some(Resource::Folder(id)),
|
||||
"file" => Some(Resource::File(id)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Permission — what action is allowed
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||
pub enum Permission {
|
||||
/// View resource content / list folder contents.
|
||||
Read,
|
||||
/// Create child resources inside (only meaningful on folders).
|
||||
Create,
|
||||
/// Grant permissions to other subjects.
|
||||
Share,
|
||||
/// Add comments (reserved — comments feature not implemented yet).
|
||||
Comment,
|
||||
/// Delete the resource.
|
||||
Delete,
|
||||
/// Modify the resource (rename, move, edit content).
|
||||
Update,
|
||||
}
|
||||
|
||||
impl Permission {
|
||||
/// Every permission, in a stable order. Used by `Role::expand()` and SQL
|
||||
/// `permission = ANY(...)` lookups.
|
||||
pub const ALL: [Permission; 6] = [
|
||||
Permission::Read,
|
||||
Permission::Create,
|
||||
Permission::Share,
|
||||
Permission::Comment,
|
||||
Permission::Delete,
|
||||
Permission::Update,
|
||||
];
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Permission::Read => "read",
|
||||
Permission::Create => "create",
|
||||
Permission::Share => "share",
|
||||
Permission::Comment => "comment",
|
||||
Permission::Delete => "delete",
|
||||
Permission::Update => "update",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a permission from its SQL discriminator string. Returns None
|
||||
/// for unknown values.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"read" => Some(Permission::Read),
|
||||
"create" => Some(Permission::Create),
|
||||
"share" => Some(Permission::Share),
|
||||
"comment" => Some(Permission::Comment),
|
||||
"delete" => Some(Permission::Delete),
|
||||
"update" => Some(Permission::Update),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Grant — a row in storage.access_grants
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Grant {
|
||||
pub id: Uuid,
|
||||
pub subject: Subject,
|
||||
pub resource: Resource,
|
||||
pub permission: Permission,
|
||||
pub granted_by: Uuid,
|
||||
pub granted_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn subject_roundtrip() {
|
||||
let id = Uuid::new_v4();
|
||||
let cases = [
|
||||
Subject::User(id),
|
||||
Subject::Group(id),
|
||||
Subject::Token(id),
|
||||
Subject::External(id),
|
||||
];
|
||||
for s in cases {
|
||||
let back = Subject::from_parts(s.type_str(), s.id()).unwrap();
|
||||
assert_eq!(s, back);
|
||||
}
|
||||
assert!(Subject::from_parts("unknown", id).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_roundtrip() {
|
||||
let id = Uuid::new_v4();
|
||||
for r in [Resource::Folder(id), Resource::File(id)] {
|
||||
let back = Resource::from_parts(r.type_str(), r.id()).unwrap();
|
||||
assert_eq!(r, back);
|
||||
}
|
||||
assert!(Resource::from_parts("calendar", id).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_roundtrip() {
|
||||
for p in Permission::ALL {
|
||||
assert_eq!(Permission::parse(p.as_str()), Some(p));
|
||||
}
|
||||
assert!(Permission::parse("administrate").is_none());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user