diff --git a/Cargo.toml b/Cargo.toml index d12e934b6..25ae08866 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "libra", "vault", "neptune", + "saturn", "lunar/src-tauri", ] default-members = ["mega", "libra"] @@ -27,6 +28,7 @@ callisto = { path = "jupiter/callisto" } gemini = { path = "gemini" } vault = { path = "vault" } neptune = { path = "neptune" } +saturn = { path = "saturn" } mega = { path = "mega" } anyhow = "1.0.86" serde = "1.0.203" @@ -67,3 +69,4 @@ num_cpus = "1.16.0" config = "0.14.0" shadow-rs = "0.30.0" reqwest = "0.12.5" +lazy_static = "1.5.0" diff --git a/saturn/Cargo.toml b/saturn/Cargo.toml new file mode 100644 index 000000000..7492cc1b5 --- /dev/null +++ b/saturn/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "saturn" +version = "0.1.0" +edition = "2021" + +[dependencies] +common = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread"] } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +thiserror = { workspace = true } + +itertools = "0.13.0" +cedar-policy = "3.2.1" diff --git a/saturn/entities.json b/saturn/entities.json new file mode 100644 index 000000000..ff6f040b0 --- /dev/null +++ b/saturn/entities.json @@ -0,0 +1,78 @@ +{ + "users": { + "User::\"kesha\"": { + "euid": "User::\"kesha\"", + "parents": [ + "UserGroup::\"mega_admin\"" + ] + }, + "User::\"alice\"": { + "euid": "User::\"alice\"", + "parents": [ + "UserGroup::\"mega_reader\"" + ] + } + }, + "repos": { + "Repository::\"mega\"": { + "euid": "Repository::\"mega\"", + "is_private": true, + "admins": "UserGroup::\"mega_admin\"", + "maintainers": "UserGroup::\"mega_matainer\"", + "writers": "UserGroup::\"mega_writer\"", + "triagers": "UserGroup::\"mega_triager\"", + "readers": "UserGroup::\"mega_reader\"", + "parents": [] + }, + "Repository::\"public\"": { + "euid": "Repository::\"public\"", + "is_private": false, + "admins": "UserGroup::\"placeholder\"", + "maintainers": "UserGroup::\"placeholder\"", + "writers": "UserGroup::\"placeholder\"", + "triagers": "UserGroup::\"placeholder\"", + "readers": "UserGroup::\"placeholder\"", + "parents": [] + } + }, + "user_groups": { + "UserGroup::\"mega_admin\"": { + "euid": "UserGroup::\"mega_admin\"", + "parents": [ + "UserGroup::\"mega_matainer\"" + ] + }, + "UserGroup::\"mega_matainer\"": { + "euid": "UserGroup::\"mega_matainer\"", + "parents": [ + "UserGroup::\"mega_writer\"" + ] + }, + "UserGroup::\"mega_writer\"": { + "euid": "UserGroup::\"mega_writer\"", + "parents": [ + "UserGroup::\"mega_triager\"" + ] + }, + "UserGroup::\"mega_triager\"": { + "euid": "UserGroup::\"mega_triager\"", + "parents": [ + "UserGroup::\"mega_reader\"" + ] + }, + "UserGroup::\"mega_reader\"": { + "euid": "UserGroup::\"mega_reader\"", + "parents": [] + }, + "UserGroup::\"placeholder\"": { + "euid": "UserGroup::\"placeholder\"", + "parents": [] + } + }, + "merge_requests": { + + }, + "issues": { + + } +} \ No newline at end of file diff --git a/saturn/mega.cedarschema b/saturn/mega.cedarschema new file mode 100644 index 000000000..f3c5cd6a4 --- /dev/null +++ b/saturn/mega.cedarschema @@ -0,0 +1,51 @@ +entity UserGroup in [UserGroup]; +entity User in [UserGroup, Team]; + +entity Team in [UserGroup] { + "admins": UserGroup, + "members": UserGroup, +}; + +entity Repository = { + "is_private": Bool, + "admins": UserGroup, + "maintainers": UserGroup, + "readers": UserGroup, + "triagers": UserGroup, + "writers": UserGroup, +}; + +entity MergeRequest = { + "repo": Repository, + "owner": User, +}; + +entity Issue = { + "repo": Repository, + "owner": User, +}; + +action "deleteRepo", "viewRepo", "forkRepo", "pullRepo", "pushRepo" appliesTo { + principal: [User], + resource: [Repository], +}; + +action "createMergeRequest", "closeMergeRequest", "approveMergeRequest" appliesTo { + principal: [User], + resource: [MergeRequest], +}; + +action "createTeam", "viewTeam", "addMember", "removeMember", "deleteTeam" appliesTo { + principal: [User], + resource: [Team], +}; + +action "openIssue", "assignIssue", "deleteIssue", "editIssue" appliesTo { + principal: [User], + resource: [Issue], +}; + +action "addReader", "addWriter", "addMaintainer", "addAdmin", "addTriager" appliesTo { + principal: [User], + resource: [Repository], +}; \ No newline at end of file diff --git a/saturn/mega_policies.cedar b/saturn/mega_policies.cedar new file mode 100644 index 000000000..d8c549cfd --- /dev/null +++ b/saturn/mega_policies.cedar @@ -0,0 +1,95 @@ +// policy "anyoneCanViewPublicRepo" even if not login? +permit ( + principal, + action in [Action::"viewRepo"], + resource +) +unless { resource.is_private }; + +// policy "adminCanManageTeams" +// permit ( +// principal, +// action in +// [Action::"createTeam", +// Action::"deleteTeam", +// Action::"viewTeam", +// Action::"addMember", +// Action::"removeMember"], +// resource +// ) +// when { principal.role == "admin" }; + +// policy "memberCanViewTeam" +permit ( + principal, + action in [Action::"viewTeam"], + resource +) +when { principal in resource.members }; + +//Actions for readers +permit ( + principal, + action in [Action::"viewRepo", Action::"pullRepo", Action::"forkRepo"], + resource +) +when { principal in resource.readers }; + +permit ( + principal, + action in [Action::"openIssue", Action::"createMergeRequest"], + resource +) +when { principal in resource.repo.readers }; + +permit ( + principal, + action in [Action::"editIssue"], + resource +) +when { principal in resource.repo.readers && principal == resource.owner }; + +//Actions for triagers +permit ( + principal, + action == Action::"assignIssue", + resource +) +when { principal in resource.repo.triagers }; + +//Actions for writers +permit ( + principal, + action == Action::"pushRepo", + resource +) +when { principal in resource.writers }; + +permit ( + principal, + action in [Action::"editIssue", Action::"approveMergeRequest"], + resource +) +when { principal in resource.repo.writers }; + +//Actions for maintainers +permit ( + principal, + action in [Action::"deleteIssue", Action::"approveMergeRequest"], + resource +) +when { principal in resource.repo.maintainers }; + +//Actions for admins +permit ( + principal, + action in + [Action::"addReader", + Action::"addTriager", + Action::"addWriter", + Action::"addMaintainer", + Action::"addAdmin", + Action::"deleteRepo"], + resource +) +when { principal in resource.admins }; \ No newline at end of file diff --git a/saturn/src/context.rs b/saturn/src/context.rs new file mode 100644 index 000000000..fa88c5395 --- /dev/null +++ b/saturn/src/context.rs @@ -0,0 +1,112 @@ +use cedar_policy::{ + Authorizer, Context, Decision, Diagnostics, HumanSchemaError, ParseErrors, PolicySet, + PolicySetError, Request, Schema, SchemaError, ValidationMode, Validator, +}; +use itertools::Itertools; +use std::path::PathBuf; +use thiserror::Error; + +use crate::{entitystore::EntityStore, util::EntityUid}; + +#[allow(dead_code)] +pub struct AppContext { + entities: EntityStore, + authorizer: Authorizer, + policies: PolicySet, + schema: Schema, +} + +#[derive(Debug, Error)] +pub enum ContextError { + #[error("{0}")] + IO(#[from] std::io::Error), + #[error("Error Parsing Json Schema: {0}")] + JsonSchema(#[from] SchemaError), + #[error("Error Parsing Human-readable Schema: {0}")] + CedarSchema(#[from] HumanSchemaError), + #[error("Error Parsing PolicySet: {0}")] + Policy(#[from] ParseErrors), + #[error("Error Processing PolicySet: {0}")] + PolicySet(#[from] PolicySetError), + #[error("Validation Failed: {0}")] + Validation(String), + #[error("Error Deserializing Json: {0}")] + Json(#[from] serde_json::Error), +} + +#[derive(Debug, Error)] +pub enum Error { + #[error("Authorization Denied")] + AuthDenied(Diagnostics), + #[error("Error constructing authorization request: {0}")] + Request(String), +} + +impl AppContext { + pub fn new( + entities_path: impl Into, + schema_path: impl Into, + policies_path: impl Into, + ) -> Result { + let schema_path = schema_path.into(); + let policies_path = policies_path.into(); + + let schema_file = std::fs::File::open(schema_path)?; + let (schema, _) = Schema::from_file_natural(schema_file).unwrap(); + let entities_file = std::fs::File::open(entities_path.into())?; + let entities = serde_json::from_reader(entities_file)?; + let policy_src = std::fs::read_to_string(policies_path)?; + let policies = policy_src.parse()?; + let validator = Validator::new(schema.clone()); + let output = validator.validate(&policies, ValidationMode::default()); + + if output.validation_passed() { + tracing::info!("All policy validation passed!"); + let authorizer = Authorizer::new(); + let c = Self { + entities, + authorizer, + policies, + schema, + }; + + Ok(c) + } else { + let error_string = output + .validation_errors() + .map(|err| format!("{err}")) + .join("\n"); + Err(ContextError::Validation(error_string)) + } + } + + pub fn is_authorized( + &self, + principal: impl AsRef, + action: impl AsRef, + resource: impl AsRef, + context: Context, + ) -> Result<(), Error> { + let es = self.entities.as_entities(&self.schema); + let q = Request::new( + Some(principal.as_ref().clone().into()), + Some(action.as_ref().clone().into()), + Some(resource.as_ref().clone().into()), + context, + Some(&self.schema), + ) + .map_err(|e| Error::Request(e.to_string()))?; + tracing::info!( + "is_authorized request: principal: {}, action: {}, resource: {}", + principal.as_ref(), + action.as_ref(), + resource.as_ref() + ); + let response = self.authorizer.is_authorized(&q, &self.policies, &es); + tracing::info!("Auth response: {:?}", response); + match response.decision() { + Decision::Allow => Ok(()), + Decision::Deny => Err(Error::AuthDenied(response.diagnostics().clone())), + } + } +} diff --git a/saturn/src/entitystore.rs b/saturn/src/entitystore.rs new file mode 100644 index 000000000..bd0c0a2b9 --- /dev/null +++ b/saturn/src/entitystore.rs @@ -0,0 +1,34 @@ +use std::collections::HashMap; + +use cedar_policy::{Entities, Schema}; +use serde::{Deserialize, Serialize}; + +use crate::{ + objects::{Issue, MergeRequest, Repo, User, UserGroup}, + util::EntityUid, +}; + +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct EntityStore { + users: HashMap, + repos: HashMap, + merge_requests: HashMap, + issues: HashMap, + user_groups: HashMap, +} + +impl EntityStore { + pub fn as_entities(&self, schema: &Schema) -> Entities { + let users = self.users.values().map(|user| user.clone().into()); + let repos = self.repos.values().map(|repo| repo.clone().into()); + let merge_requests = self.merge_requests.values().map(|user| user.clone().into()); + let issues = self.issues.values().map(|repo| repo.clone().into()); + let user_groups = self.user_groups.values().map(|group| group.clone().into()); + let all = users + .chain(repos) + .chain(user_groups) + .chain(merge_requests) + .chain(issues); + Entities::from_entities(all, Some(schema)).unwrap() + } +} diff --git a/saturn/src/main.rs b/saturn/src/main.rs new file mode 100644 index 000000000..e620e974a --- /dev/null +++ b/saturn/src/main.rs @@ -0,0 +1,132 @@ +use cedar_policy::Context; +use context::AppContext; +use util::EntityUid; + +mod context; +mod entitystore; +mod objects; +mod util; + +fn main() { + tracing_subscriber::fmt().pretty().init(); + let current_dir = env!("CARGO_MANIFEST_DIR"); + let (schema_path, policies_path, entities_path) = ( + format!("{}/{}", current_dir, "mega.cedarschema"), + format!("{}/{}", current_dir, "mega_policies.cedar"), + format!("{}/{}", current_dir, "./entities.json"), + ); + + let app_context = match AppContext::new(entities_path, schema_path, policies_path) { + Ok(app) => app, + Err(e) => { + tracing::error!("Failed to load entities, policies, or schema: {e}"); + std::process::exit(1); + } + }; + let anyone: EntityUid = r#"User::"anyone""#.parse().unwrap(); + let resource: EntityUid = r#"Repository::"public""#.parse().unwrap(); + // anyone can view public_repo + assert!(app_context + .is_authorized( + anyone, + r#"Action::"viewRepo""#.parse::().unwrap(), + resource.clone(), + Context::empty() + ) + .is_ok()); +} + +#[cfg(test)] +mod test { + use cedar_policy::{Authorizer, Context, Entities, PolicySet, Request}; + + use crate::{context::AppContext, util::EntityUid}; + + #[test] + fn test_origin_policy() { + const POLICY_SRC: &str = r#" + permit(principal == User::"alice", action == Action::"view", resource == File::"93"); + "#; + let policy: PolicySet = POLICY_SRC.parse().unwrap(); + + let action = r#"Action::"view""#.parse().unwrap(); + let alice = r#"User::"alice""#.parse().unwrap(); + let file = r#"File::"93""#.parse().unwrap(); + let request = Request::new( + Some(alice), + Some(action), + Some(file), + Context::empty(), + None, + ) + .unwrap(); + + let entities = Entities::empty(); + let authorizer = Authorizer::new(); + let answer = authorizer.is_authorized(&request, &policy, &entities); + + // Should output `Allow` + println!("{:?}", answer.decision()); + + let action = r#"Action::"view""#.parse().unwrap(); + let bob = r#"User::"bob""#.parse().unwrap(); + let file = r#"File::"93""#.parse().unwrap(); + let request = + Request::new(Some(bob), Some(action), Some(file), Context::empty(), None).unwrap(); + + let answer = authorizer.is_authorized(&request, &policy, &entities); + + // Should output `Deny` + println!("{:?}", answer.decision()); + } + + fn load_context() -> AppContext { + AppContext::new( + "./entities.json", + "./mega.cedarschema", + "./mega_policies.cedar", + ) + .unwrap() + } + #[test] + fn test_admin_policy() { + let app_context = load_context(); + let principal: EntityUid = r#"User::"kesha""#.parse().unwrap(); + let resource: EntityUid = r#"Repository::"mega""#.parse().unwrap(); + + // admin can view repo + assert!(app_context + .is_authorized( + principal.clone(), + r#"Action::"viewRepo""#.parse::().unwrap(), + resource.clone(), + Context::empty() + ) + .is_ok()); + // admin can delete repo + assert!(app_context + .is_authorized( + principal, + r#"Action::"deleteRepo""#.parse::().unwrap(), + resource.clone(), + Context::empty() + ) + .is_ok()); + } + + #[test] + fn test_anyone_policy() { + let app_context = load_context(); + let anyone: EntityUid = r#"User::"anyone""#.parse().unwrap(); + let resource: EntityUid = r#"Repository::"public""#.parse().unwrap(); + // anyone can view public_repo + assert!(app_context + .is_authorized( + anyone, + r#"Action::"viewRepo""#.parse::().unwrap(), + resource.clone(), + Context::empty() + ) + .is_ok()); + } +} diff --git a/saturn/src/objects.rs b/saturn/src/objects.rs new file mode 100644 index 000000000..9efa04cb5 --- /dev/null +++ b/saturn/src/objects.rs @@ -0,0 +1,147 @@ +use std::collections::HashSet; + +use cedar_policy::{Entity, RestrictedExpression}; +use serde::{Deserialize, Serialize}; + +use crate::util::EntityUid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct User { + euid: EntityUid, + parents: HashSet, +} + +impl From for Entity { + fn from(value: User) -> Entity { + Entity::new_no_attrs( + value.euid.into(), + value.parents.into_iter().map(|euid| euid.into()).collect(), + ) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserGroup { + euid: EntityUid, + parents: HashSet, +} + +impl From for Entity { + fn from(value: UserGroup) -> Entity { + Entity::new_no_attrs( + value.euid.into(), + value.parents.into_iter().map(|euid| euid.into()).collect(), + ) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Repo { + euid: EntityUid, + is_private: bool, + admins: EntityUid, + maintainers: EntityUid, + readers: EntityUid, + triagers: EntityUid, + writers: EntityUid, + parents: HashSet, +} + +impl From for Entity { + fn from(value: Repo) -> Self { + let attrs = [ + ( + "is_private", + RestrictedExpression::new_bool(value.is_private), + ), + ( + "admins", + format!("{}", value.admins.as_ref()).parse().unwrap(), + ), + ( + "maintainers", + format!("{}", value.maintainers.as_ref()).parse().unwrap(), + ), + ( + "readers", + format!("{}", value.readers.as_ref()).parse().unwrap(), + ), + ( + "triagers", + format!("{}", value.triagers.as_ref()).parse().unwrap(), + ), + ( + "writers", + format!("{}", value.writers.as_ref()).parse().unwrap(), + ), + ] + .into_iter() + .map(|(x, v)| (x.into(), v)) + .collect(); + + let parents = value.parents.into_iter().map(|euid| euid.into()).collect(); + + // let euid: EntityUid = value.euid.into(); + Entity::new(value.euid.into(), attrs, parents).unwrap() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MergeRequest { + euid: EntityUid, + repo: EntityUid, + owner: EntityUid, + parents: HashSet, +} + +impl From for Entity { + fn from(value: MergeRequest) -> Entity { + let attrs = [ + ("repo", format!("{}", value.repo.as_ref()).parse().unwrap()), + ( + "owner", + format!("{}", value.owner.as_ref()).parse().unwrap(), + ), + ] + .into_iter() + .map(|(x, v)| (x.into(), v)) + .collect(); + + Entity::new( + value.euid.into(), + attrs, + value.parents.into_iter().map(|euid| euid.into()).collect(), + ) + .unwrap() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Issue { + euid: EntityUid, + repo: EntityUid, + owner: EntityUid, + parents: HashSet, +} + +impl From for Entity { + fn from(value: Issue) -> Entity { + let attrs = [ + ("repo", format!("{}", value.repo.as_ref()).parse().unwrap()), + ( + "owner", + format!("{}", value.owner.as_ref()).parse().unwrap(), + ), + ] + .into_iter() + .map(|(x, v)| (x.into(), v)) + .collect(); + + Entity::new( + value.euid.into(), + attrs, + value.parents.into_iter().map(|euid| euid.into()).collect(), + ) + .unwrap() + } +} diff --git a/saturn/src/util.rs b/saturn/src/util.rs new file mode 100644 index 000000000..63d8f062d --- /dev/null +++ b/saturn/src/util.rs @@ -0,0 +1,111 @@ +use std::{ops::Deref, str::FromStr}; + +use cedar_policy::ParseErrors; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[repr(transparent)] +#[serde(transparent)] +pub struct EntityUid( + #[serde(serialize_with = "serialize_euid")] + #[serde(deserialize_with = "deserialize_euid")] + cedar_policy::EntityUid, +); + +impl AsRef for EntityUid { + fn as_ref(&self) -> &EntityUid { + self + } +} + +impl FromStr for EntityUid { + type Err = ParseErrors; + + fn from_str(s: &str) -> Result { + let e: cedar_policy::EntityUid = s.parse()?; + Ok(e.into()) + } +} + +impl std::fmt::Display for EntityUid { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for EntityUid { + fn from(value: cedar_policy::EntityUid) -> Self { + Self(value) + } +} + +impl Deref for EntityUid { + type Target = cedar_policy::EntityUid; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From for cedar_policy::EntityUid { + fn from(value: EntityUid) -> Self { + value.0 + } +} + +pub fn serialize_euid(euid: &cedar_policy::EntityUid, s: S) -> Result +where + S: Serializer, +{ + s.serialize_str(&format!("{euid}")) +} + +pub fn deserialize_euid<'de, D>(d: D) -> Result +where + D: Deserializer<'de>, +{ + struct Visitor; + + impl<'ide> serde::de::Visitor<'ide> for Visitor { + type Value = cedar_policy::EntityUid; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!( + formatter, + "Expected string that could be parsed as an EntityUid" + ) + } + + fn visit_string(self, v: String) -> Result + where + E: serde::de::Error, + { + let euid = v + .parse() + .map_err(|e| serde::de::Error::custom(format!("{e}")))?; + Ok(euid) + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + let euid = v + .parse() + .map_err(|e| serde::de::Error::custom(format!("{e}")))?; + Ok(euid) + } + + fn visit_borrowed_str(self, v: &'ide str) -> Result + where + E: serde::de::Error, + { + let euid = v + .parse() + .map_err(|e| serde::de::Error::custom(format!("{e}")))?; + Ok(euid) + } + } + + d.deserialize_str(Visitor) +}