diff options
| author | mo khan <mo@mokhan.ca> | 2025-07-10 17:49:29 -0600 |
|---|---|---|
| committer | mo khan <mo@mokhan.ca> | 2025-07-10 17:49:29 -0600 |
| commit | ef572ae666732e87a35417710669ce88233a754a (patch) | |
| tree | 3cc32004dee9600014417d404dbe01ac0e1faca9 /src | |
| parent | 8417a15087cc6f42c77fe070011ac2207f8d852d (diff) | |
| parent | 6721aaffa33894624c87a54f4ed10eccd3c080e5 (diff) | |
Merge branch 'entities' into 'main'
Use a static ACL file(s) to make authorization decisions
See merge request gitlab-org/software-supply-chain-security/authorization/authzd!6
Diffstat (limited to 'src')
| -rw-r--r-- | src/authorization/cedar_authorizer.rs | 67 | ||||
| -rw-r--r-- | src/authorization/entities.rs | 143 | ||||
| -rw-r--r-- | src/authorization/mod.rs | 2 | ||||
| -rw-r--r-- | src/bin/cli.rs | 69 | ||||
| -rw-r--r-- | src/gitlab/api.rs | 81 | ||||
| -rw-r--r-- | src/gitlab/group.rs | 10 | ||||
| -rw-r--r-- | src/gitlab/member.rs | 9 | ||||
| -rw-r--r-- | src/gitlab/mod.rs | 11 | ||||
| -rw-r--r-- | src/gitlab/namespace.rs | 11 | ||||
| -rw-r--r-- | src/gitlab/project.rs | 11 | ||||
| -rw-r--r-- | src/lib.rs | 6 |
11 files changed, 383 insertions, 37 deletions
diff --git a/src/authorization/cedar_authorizer.rs b/src/authorization/cedar_authorizer.rs index 64287414..662aafeb 100644 --- a/src/authorization/cedar_authorizer.rs +++ b/src/authorization/cedar_authorizer.rs @@ -23,7 +23,14 @@ impl CedarAuthorizer { pub fn new_from(path: &std::path::Path, entities: cedar_policy::Entities) -> CedarAuthorizer { Self::new( - Self::load_from(path).unwrap_or_else(|_| cedar_policy::PolicySet::default()), + Self::load_from(path).unwrap_or_else(|e| { + tracing::error!( + path = ?path, + error = %e, + "Failed to load Cedar policies, using empty policy set" + ); + cedar_policy::PolicySet::default() + }), entities, ) } @@ -31,27 +38,24 @@ impl CedarAuthorizer { fn load_from( path: &std::path::Path, ) -> Result<cedar_policy::PolicySet, Box<dyn std::error::Error>> { - if !path.exists() || !path.is_dir() { + if !path.exists() { return Ok(cedar_policy::PolicySet::default()); } - let mut policies = cedar_policy::PolicySet::new(); - for entry in fs::read_dir(path)? { - let file_path = entry?.path(); - - if let Some(extension) = file_path.extension() { - if extension == "cedar" { - let content = fs::read_to_string(&file_path)?; - let file_policies = cedar_policy::PolicySet::from_str(&content)?; + if path.is_file() && path.extension().map_or(false, |ext| ext == "cedar") { + let content = fs::read_to_string(&path)?; + return Ok(cedar_policy::PolicySet::from_str(&content)?); + } - for policy in file_policies.policies() { - policies.add(policy.clone())?; - } - } - } + if !path.is_dir() { + return Ok(cedar_policy::PolicySet::default()); } - Ok(policies) + let mut policies = cedar_policy::PolicySet::new(); + for entry in fs::read_dir(path)? { + policies.merge(&Self::load_from(&entry?.path())?, true)?; + } + return Ok(policies); } fn map_from( @@ -70,31 +74,36 @@ impl CedarAuthorizer { fn principal_from( &self, - _http_request: &envoy_types::pb::envoy::service::auth::v3::attribute_context::HttpRequest, + http_request: &envoy_types::pb::envoy::service::auth::v3::attribute_context::HttpRequest, ) -> Result<cedar_policy::EntityUid, Box<dyn std::error::Error>> { + let subject = http_request + .headers + .get("x-jwt-claim-sub") + .map_or("", |v| v); + Ok(cedar_policy::EntityUid::from_type_name_and_id( cedar_policy::EntityTypeName::from_str("User")?, - cedar_policy::EntityId::from_str("client")?, + cedar_policy::EntityId::from_str(subject)?, )) } fn permission_from( &self, - _http_request: &envoy_types::pb::envoy::service::auth::v3::attribute_context::HttpRequest, + http_request: &envoy_types::pb::envoy::service::auth::v3::attribute_context::HttpRequest, ) -> Result<cedar_policy::EntityUid, Box<dyn std::error::Error>> { Ok(cedar_policy::EntityUid::from_type_name_and_id( cedar_policy::EntityTypeName::from_str("Action")?, - cedar_policy::EntityId::from_str("check")?, + cedar_policy::EntityId::from_str(&http_request.method)?, )) } fn resource_from( &self, - _http_request: &envoy_types::pb::envoy::service::auth::v3::attribute_context::HttpRequest, + http_request: &envoy_types::pb::envoy::service::auth::v3::attribute_context::HttpRequest, ) -> Result<cedar_policy::EntityUid, Box<dyn std::error::Error>> { Ok(cedar_policy::EntityUid::from_type_name_and_id( cedar_policy::EntityTypeName::from_str("Resource")?, - cedar_policy::EntityId::from_str("resource")?, + cedar_policy::EntityId::from_str(&http_request.path)?, )) } @@ -104,7 +113,6 @@ impl CedarAuthorizer { ) -> Result<cedar_policy::Context, Box<dyn std::error::Error>> { let mut items = std::collections::HashMap::new(); - items.insert("bearer_token".to_string(), self.token_from(&http_request)); items.insert("host".to_string(), self.safe_string(&http_request.host)); items.insert("method".to_string(), self.safe_string(&http_request.method)); items.insert("path".to_string(), self.safe_string(&http_request.path)); @@ -114,19 +122,6 @@ impl CedarAuthorizer { )?) } - fn token_from( - &self, - http_request: &envoy_types::pb::envoy::service::auth::v3::attribute_context::HttpRequest, - ) -> cedar_policy::RestrictedExpression { - let bearer_token = &http_request - .headers - .get("authorization") - .and_then(|auth| auth.strip_prefix("Bearer ")) - .unwrap_or(""); - - self.safe_string(bearer_token) - } - fn safe_string(&self, item: &str) -> cedar_policy::RestrictedExpression { cedar_policy::RestrictedExpression::new_string(item.to_string()) } diff --git a/src/authorization/entities.rs b/src/authorization/entities.rs new file mode 100644 index 00000000..fc1246d7 --- /dev/null +++ b/src/authorization/entities.rs @@ -0,0 +1,143 @@ +use crate::gitlab::Api; +use serde::Serialize; +use std::collections::HashSet; + +// Cedar entity structures +// Note: We define custom types instead of using cedar_policy::Entity directly because: +// 1. Cedar's Entity type is for runtime use, not JSON serialization +// 2. These types ensure our JSON output matches Cedar's expected format exactly +// 3. The #[serde(rename)] attributes handle Cedar's specific field naming requirements +#[derive(Debug, Serialize)] +pub struct CedarEntity { + pub uid: CedarUid, + pub attrs: serde_json::Value, + pub parents: Vec<CedarParent>, +} + +#[derive(Debug, Serialize)] +pub struct CedarUid { + #[serde(rename = "type")] + pub entity_type: String, + pub id: String, +} + +#[derive(Debug, Serialize)] +pub struct CedarParent { + #[serde(rename = "type")] + pub parent_type: String, + pub id: String, +} + +pub struct EntitiesRepository { + api: Api, +} + +impl EntitiesRepository { + pub fn new(api: Api) -> EntitiesRepository { + EntitiesRepository { api: api } + } + + pub async fn all( + &self, + project_path: String, + ) -> Result<Vec<CedarEntity>, Box<dyn std::error::Error>> { + let mut entities = Vec::new(); + let mut groups = HashSet::new(); + + let project = self.api.get_project(&project_path).await?; + + entities.push(CedarEntity { + uid: CedarUid { + entity_type: "Project".to_string(), + id: project.id.to_string(), + }, + attrs: serde_json::json!({ + "name": project.name, + "path": project.path, + "full_path": format!("{}/{}", project.namespace.full_path, project.path), + }), + parents: if project.namespace.kind == "group" { + vec![CedarParent { + parent_type: "Group".to_string(), + id: project.namespace.id.to_string(), + }] + } else { + vec![] + }, + }); + + for member in self.api.get_project_members(project.id).await? { + if member.state == "active" { + entities.push(CedarEntity { + uid: CedarUid { + entity_type: "User".to_string(), + id: member.id.to_string(), + }, + attrs: serde_json::json!({ + "username": member.username, + "access_level": member.access_level, + }), + parents: vec![], + }); + } + } + + if project.namespace.kind == "group" { + self.fetch_hierarchy(project.namespace.id, &mut entities, &mut groups) + .await?; + } + + Ok(entities) + } + + /// Validates that the entities can be parsed by Cedar + pub fn is_valid(entities: &[CedarEntity]) -> Result<(), Box<dyn std::error::Error>> { + let json = serde_json::to_string(entities)?; + cedar_policy::Entities::from_json_str(&json, None)?; + Ok(()) + } + + fn fetch_hierarchy<'a>( + &'a self, + group_id: u64, + entities: &'a mut Vec<CedarEntity>, + groups: &'a mut HashSet<u64>, + ) -> std::pin::Pin< + Box<dyn std::future::Future<Output = Result<(), Box<dyn std::error::Error>>> + 'a>, + > { + Box::pin(async move { + if groups.contains(&group_id) { + return Ok(()); + } + + groups.insert(group_id); + + let group = self.api.get_group(group_id).await?; + + let parents = if let Some(parent_id) = group.parent_id { + self.fetch_hierarchy(parent_id, entities, groups).await?; + vec![CedarParent { + parent_type: "Group".to_string(), + id: parent_id.to_string(), + }] + } else { + vec![] + }; + + entities.push(CedarEntity { + uid: CedarUid { + entity_type: "Group".to_string(), + id: group.id.to_string(), + }, + attrs: serde_json::json!({ + "name": group.name, + "path": group.path, + "full_path": group.full_path, + }), + parents, + }); + + Ok(()) + }) + } +} diff --git a/src/authorization/mod.rs b/src/authorization/mod.rs index d664815b..d687d53f 100644 --- a/src/authorization/mod.rs +++ b/src/authorization/mod.rs @@ -1,9 +1,11 @@ pub mod authorizer; pub mod cedar_authorizer; pub mod check_service; +pub mod entities; pub mod server; pub use authorizer::Authorizer; pub use cedar_authorizer::CedarAuthorizer; pub use check_service::CheckService; +pub use entities::{CedarEntity, EntitiesRepository}; pub use server::Server; diff --git a/src/bin/cli.rs b/src/bin/cli.rs new file mode 100644 index 00000000..fc70ae82 --- /dev/null +++ b/src/bin/cli.rs @@ -0,0 +1,69 @@ +use authzd::EntitiesRepository; +use authzd::gitlab::Api; +use clap::{Parser, Subcommand}; + +#[derive(Parser, Debug)] +#[command( + author, + version, + about = "Authorization CLI for managing Cedar entities and policies" +)] +struct Args { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// Generate entities from GitLab API + Generate { + /// Project ID or path (e.g., gitlab-org/gitlab) + #[arg(short, long)] + project: String, + + /// Output file path + #[arg(short, long, default_value = "entities.json")] + output: String, + + /// GitLab API token + #[arg(short, long, env = "GITLAB_TOKEN")] + token: String, + + /// GitLab instance URL + #[arg( + short = 'H', + long, + env = "GITLAB_HOST", + default_value = "https://gitlab.com" + )] + host: String, + }, +} + +#[tokio::main] +async fn main() -> Result<(), Box<dyn std::error::Error>> { + let args = Args::parse(); + + match args.command { + Commands::Generate { + project, + output, + token, + host, + } => { + let repository = EntitiesRepository::new(Api::new(token, host)); + let entities = repository.all(project).await?; + EntitiesRepository::is_valid(&entities)?; + let json = serde_json::to_string_pretty(&entities)?; + std::fs::write(&output, json)?; + + println!( + "Successfully generated {} entities to {}", + entities.len(), + output + ); + } + } + + Ok(()) +} diff --git a/src/gitlab/api.rs b/src/gitlab/api.rs new file mode 100644 index 00000000..7f733b4c --- /dev/null +++ b/src/gitlab/api.rs @@ -0,0 +1,81 @@ +use crate::gitlab::{Group, Member, Project}; +use reqwest::Client; + +pub struct Api { + pub token: String, + pub host: String, + client: Client, +} + +impl Api { + pub fn new(token: String, host: String) -> Api { + Api { + token, + host, + client: Client::new(), + } + } + + pub async fn get_project(&self, project: &str) -> Result<Project, Box<dyn std::error::Error>> { + let url = format!( + "{}/api/v4/projects/{}", + self.host.trim_end_matches('/'), + urlencoding::encode(project) + ); + + let project = self + .client + .get(&url) + .header("PRIVATE-TOKEN", &self.token) + .send() + .await? + .error_for_status()? + .json() + .await?; + + Ok(project) + } + + pub async fn get_project_members( + &self, + project_id: u64, + ) -> Result<Vec<Member>, Box<dyn std::error::Error>> { + let url = format!( + "{}/api/v4/projects/{}/members/all", + self.host.trim_end_matches('/'), + project_id + ); + + let members = self + .client + .get(&url) + .header("PRIVATE-TOKEN", &self.token) + .send() + .await? + .error_for_status()? + .json() + .await?; + + Ok(members) + } + + pub async fn get_group(&self, group_id: u64) -> Result<Group, Box<dyn std::error::Error>> { + let url = format!( + "{}/api/v4/groups/{}", + self.host.trim_end_matches('/'), + group_id + ); + + let group = self + .client + .get(&url) + .header("PRIVATE-TOKEN", &self.token) + .send() + .await? + .error_for_status()? + .json() + .await?; + + Ok(group) + } +} diff --git a/src/gitlab/group.rs b/src/gitlab/group.rs new file mode 100644 index 00000000..6b00e87d --- /dev/null +++ b/src/gitlab/group.rs @@ -0,0 +1,10 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct Group { + pub id: u64, + pub name: String, + pub path: String, + pub full_path: String, + pub parent_id: Option<u64>, +} diff --git a/src/gitlab/member.rs b/src/gitlab/member.rs new file mode 100644 index 00000000..b44b88f2 --- /dev/null +++ b/src/gitlab/member.rs @@ -0,0 +1,9 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct Member { + pub id: u64, + pub username: String, + pub state: String, + pub access_level: u64, +} diff --git a/src/gitlab/mod.rs b/src/gitlab/mod.rs new file mode 100644 index 00000000..e1993d81 --- /dev/null +++ b/src/gitlab/mod.rs @@ -0,0 +1,11 @@ +pub mod api; +pub mod group; +pub mod member; +pub mod namespace; +pub mod project; + +pub use api::Api; +pub use group::Group; +pub use member::Member; +pub use namespace::Namespace; +pub use project::Project; diff --git a/src/gitlab/namespace.rs b/src/gitlab/namespace.rs new file mode 100644 index 00000000..d4a1e8f4 --- /dev/null +++ b/src/gitlab/namespace.rs @@ -0,0 +1,11 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct Namespace { + pub id: u64, + pub name: String, + pub path: String, + pub kind: String, + pub full_path: String, + pub parent_id: Option<u64>, +} diff --git a/src/gitlab/project.rs b/src/gitlab/project.rs new file mode 100644 index 00000000..ba88c2e3 --- /dev/null +++ b/src/gitlab/project.rs @@ -0,0 +1,11 @@ +use serde::Deserialize; + +use super::Namespace; + +#[derive(Debug, Deserialize)] +pub struct Project { + pub id: u64, + pub name: String, + pub path: String, + pub namespace: Namespace, +} @@ -1,2 +1,6 @@ pub mod authorization; -pub use authorization::{Authorizer, CedarAuthorizer, CheckService, Server}; +pub mod gitlab; + +pub use authorization::{ + Authorizer, CedarAuthorizer, CedarEntity, CheckService, EntitiesRepository, Server, +}; |
