mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-27 02:39:38 +02:00
Initial public Omnigraph repository
This commit is contained in:
commit
338289656a
110 changed files with 60747 additions and 0 deletions
395
crates/omnigraph-server/src/api.rs
Normal file
395
crates/omnigraph-server/src/api.rs
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
use omnigraph::db::{GraphCommit, MergeOutcome, ReadTarget, RunRecord, Snapshot};
|
||||
use omnigraph::error::{MergeConflict, MergeConflictKind};
|
||||
use omnigraph::loader::{IngestResult, LoadMode};
|
||||
use omnigraph_compiler::result::QueryResult;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SnapshotTableOutput {
|
||||
pub table_key: String,
|
||||
pub table_path: String,
|
||||
pub table_version: u64,
|
||||
pub table_branch: Option<String>,
|
||||
pub row_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SnapshotOutput {
|
||||
pub branch: String,
|
||||
pub manifest_version: u64,
|
||||
pub tables: Vec<SnapshotTableOutput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RunOutput {
|
||||
pub run_id: String,
|
||||
pub target_branch: String,
|
||||
pub run_branch: String,
|
||||
pub base_snapshot_id: String,
|
||||
pub base_manifest_version: u64,
|
||||
pub operation_hash: Option<String>,
|
||||
pub actor_id: Option<String>,
|
||||
pub status: String,
|
||||
pub published_snapshot_id: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RunListOutput {
|
||||
pub runs: Vec<RunOutput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BranchCreateRequest {
|
||||
pub from: Option<String>,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BranchCreateOutput {
|
||||
pub uri: String,
|
||||
pub from: String,
|
||||
pub name: String,
|
||||
pub actor_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BranchListOutput {
|
||||
pub branches: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BranchDeleteOutput {
|
||||
pub uri: String,
|
||||
pub name: String,
|
||||
pub actor_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BranchMergeRequest {
|
||||
pub source: String,
|
||||
pub target: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BranchMergeOutcome {
|
||||
AlreadyUpToDate,
|
||||
FastForward,
|
||||
Merged,
|
||||
}
|
||||
|
||||
impl From<MergeOutcome> for BranchMergeOutcome {
|
||||
fn from(value: MergeOutcome) -> Self {
|
||||
match value {
|
||||
MergeOutcome::AlreadyUpToDate => Self::AlreadyUpToDate,
|
||||
MergeOutcome::FastForward => Self::FastForward,
|
||||
MergeOutcome::Merged => Self::Merged,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BranchMergeOutcome {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::AlreadyUpToDate => "already_up_to_date",
|
||||
Self::FastForward => "fast_forward",
|
||||
Self::Merged => "merged",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BranchMergeOutput {
|
||||
pub source: String,
|
||||
pub target: String,
|
||||
pub outcome: BranchMergeOutcome,
|
||||
pub actor_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MergeConflictKindOutput {
|
||||
DivergentInsert,
|
||||
DivergentUpdate,
|
||||
DeleteVsUpdate,
|
||||
OrphanEdge,
|
||||
UniqueViolation,
|
||||
CardinalityViolation,
|
||||
ValueConstraintViolation,
|
||||
}
|
||||
|
||||
impl MergeConflictKindOutput {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::DivergentInsert => "divergent_insert",
|
||||
Self::DivergentUpdate => "divergent_update",
|
||||
Self::DeleteVsUpdate => "delete_vs_update",
|
||||
Self::OrphanEdge => "orphan_edge",
|
||||
Self::UniqueViolation => "unique_violation",
|
||||
Self::CardinalityViolation => "cardinality_violation",
|
||||
Self::ValueConstraintViolation => "value_constraint_violation",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MergeConflictKind> for MergeConflictKindOutput {
|
||||
fn from(value: MergeConflictKind) -> Self {
|
||||
match value {
|
||||
MergeConflictKind::DivergentInsert => Self::DivergentInsert,
|
||||
MergeConflictKind::DivergentUpdate => Self::DivergentUpdate,
|
||||
MergeConflictKind::DeleteVsUpdate => Self::DeleteVsUpdate,
|
||||
MergeConflictKind::OrphanEdge => Self::OrphanEdge,
|
||||
MergeConflictKind::UniqueViolation => Self::UniqueViolation,
|
||||
MergeConflictKind::CardinalityViolation => Self::CardinalityViolation,
|
||||
MergeConflictKind::ValueConstraintViolation => Self::ValueConstraintViolation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MergeConflictOutput {
|
||||
pub table_key: String,
|
||||
pub row_id: Option<String>,
|
||||
pub kind: MergeConflictKindOutput,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl From<&MergeConflict> for MergeConflictOutput {
|
||||
fn from(value: &MergeConflict) -> Self {
|
||||
Self {
|
||||
table_key: value.table_key.clone(),
|
||||
row_id: value.row_id.clone(),
|
||||
kind: value.kind.into(),
|
||||
message: value.message.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReadTargetOutput {
|
||||
pub branch: Option<String>,
|
||||
pub snapshot: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReadOutput {
|
||||
pub query_name: String,
|
||||
pub target: ReadTargetOutput,
|
||||
pub row_count: usize,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub columns: Vec<String>,
|
||||
pub rows: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChangeOutput {
|
||||
pub branch: String,
|
||||
pub query_name: String,
|
||||
pub affected_nodes: usize,
|
||||
pub affected_edges: usize,
|
||||
pub actor_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IngestTableOutput {
|
||||
pub table_key: String,
|
||||
pub rows_loaded: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IngestOutput {
|
||||
pub uri: String,
|
||||
pub branch: String,
|
||||
pub base_branch: String,
|
||||
pub branch_created: bool,
|
||||
pub mode: LoadMode,
|
||||
pub tables: Vec<IngestTableOutput>,
|
||||
pub actor_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CommitOutput {
|
||||
pub graph_commit_id: String,
|
||||
pub manifest_branch: Option<String>,
|
||||
pub manifest_version: u64,
|
||||
pub parent_commit_id: Option<String>,
|
||||
pub merged_parent_commit_id: Option<String>,
|
||||
pub actor_id: Option<String>,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CommitListOutput {
|
||||
pub commits: Vec<CommitOutput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReadRequest {
|
||||
pub query_source: String,
|
||||
pub query_name: Option<String>,
|
||||
pub params: Option<Value>,
|
||||
pub branch: Option<String>,
|
||||
pub snapshot: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChangeRequest {
|
||||
pub query_source: String,
|
||||
pub query_name: Option<String>,
|
||||
pub params: Option<Value>,
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IngestRequest {
|
||||
pub branch: Option<String>,
|
||||
pub from: Option<String>,
|
||||
pub mode: Option<LoadMode>,
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExportRequest {
|
||||
pub branch: Option<String>,
|
||||
#[serde(default)]
|
||||
pub type_names: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub table_keys: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SnapshotQuery {
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct CommitListQuery {
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HealthOutput {
|
||||
pub status: String,
|
||||
pub version: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub source_version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ErrorCode {
|
||||
Unauthorized,
|
||||
Forbidden,
|
||||
BadRequest,
|
||||
NotFound,
|
||||
Conflict,
|
||||
Internal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ErrorOutput {
|
||||
pub error: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code: Option<ErrorCode>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub merge_conflicts: Vec<MergeConflictOutput>,
|
||||
}
|
||||
|
||||
pub fn snapshot_payload(branch: &str, snapshot: &Snapshot) -> SnapshotOutput {
|
||||
let mut entries: Vec<_> = snapshot.entries().cloned().collect();
|
||||
entries.sort_by(|a, b| a.table_key.cmp(&b.table_key));
|
||||
let tables = entries
|
||||
.iter()
|
||||
.map(|entry| SnapshotTableOutput {
|
||||
table_key: entry.table_key.clone(),
|
||||
table_path: entry.table_path.clone(),
|
||||
table_version: entry.table_version,
|
||||
table_branch: entry.table_branch.clone(),
|
||||
row_count: entry.row_count,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
SnapshotOutput {
|
||||
branch: branch.to_string(),
|
||||
manifest_version: snapshot.version(),
|
||||
tables,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_output(run: &RunRecord) -> RunOutput {
|
||||
RunOutput {
|
||||
run_id: run.run_id.as_str().to_string(),
|
||||
target_branch: run.target_branch.clone(),
|
||||
run_branch: run.run_branch.clone(),
|
||||
base_snapshot_id: run.base_snapshot_id.as_str().to_string(),
|
||||
base_manifest_version: run.base_manifest_version,
|
||||
operation_hash: run.operation_hash.clone(),
|
||||
actor_id: run.actor_id.clone(),
|
||||
status: run.status.as_str().to_string(),
|
||||
published_snapshot_id: run.published_snapshot_id.clone(),
|
||||
created_at: run.created_at,
|
||||
updated_at: run.updated_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn commit_output(commit: &GraphCommit) -> CommitOutput {
|
||||
CommitOutput {
|
||||
graph_commit_id: commit.graph_commit_id.clone(),
|
||||
manifest_branch: commit.manifest_branch.clone(),
|
||||
manifest_version: commit.manifest_version,
|
||||
parent_commit_id: commit.parent_commit_id.clone(),
|
||||
merged_parent_commit_id: commit.merged_parent_commit_id.clone(),
|
||||
actor_id: commit.actor_id.clone(),
|
||||
created_at: commit.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_output(query_name: String, target: &ReadTarget, result: QueryResult) -> ReadOutput {
|
||||
let columns = result
|
||||
.schema()
|
||||
.fields()
|
||||
.iter()
|
||||
.map(|field| field.name().clone())
|
||||
.collect();
|
||||
ReadOutput {
|
||||
query_name,
|
||||
target: read_target_output(target),
|
||||
row_count: result.num_rows(),
|
||||
columns,
|
||||
rows: result.to_rust_json(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ingest_output(uri: &str, result: &IngestResult, actor_id: Option<String>) -> IngestOutput {
|
||||
IngestOutput {
|
||||
uri: uri.to_string(),
|
||||
branch: result.branch.clone(),
|
||||
base_branch: result.base_branch.clone(),
|
||||
branch_created: result.branch_created,
|
||||
mode: result.mode,
|
||||
tables: result
|
||||
.tables
|
||||
.iter()
|
||||
.map(|table| IngestTableOutput {
|
||||
table_key: table.table_key.clone(),
|
||||
rows_loaded: table.rows_loaded,
|
||||
})
|
||||
.collect(),
|
||||
actor_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_target_output(target: &ReadTarget) -> ReadTargetOutput {
|
||||
match target {
|
||||
ReadTarget::Branch(branch) => ReadTargetOutput {
|
||||
branch: Some(branch.clone()),
|
||||
snapshot: None,
|
||||
},
|
||||
ReadTarget::Snapshot(snapshot) => ReadTargetOutput {
|
||||
branch: None,
|
||||
snapshot: Some(snapshot.as_str().to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
479
crates/omnigraph-server/src/config.rs
Normal file
479
crates/omnigraph-server/src/config.rs
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
use std::collections::BTreeMap;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use clap::ValueEnum;
|
||||
use color_eyre::eyre::{Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub const DEFAULT_CONFIG_FILE: &str = "omnigraph.yaml";
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ProjectConfig {
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TargetConfig {
|
||||
pub uri: String,
|
||||
pub bearer_token_env: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize, ValueEnum)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ReadOutputFormat {
|
||||
#[default]
|
||||
Table,
|
||||
Kv,
|
||||
Csv,
|
||||
Jsonl,
|
||||
Json,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Serialize, Deserialize, ValueEnum)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TableCellLayout {
|
||||
#[default]
|
||||
Truncate,
|
||||
Wrap,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct CliDefaults {
|
||||
pub target: Option<String>,
|
||||
pub branch: Option<String>,
|
||||
pub output_format: Option<ReadOutputFormat>,
|
||||
pub table_max_column_width: Option<usize>,
|
||||
pub table_cell_layout: Option<TableCellLayout>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ServerDefaults {
|
||||
pub target: Option<String>,
|
||||
pub bind: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct AuthDefaults {
|
||||
pub env_file: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct QueryDefaults {
|
||||
#[serde(default)]
|
||||
pub roots: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct PolicySettings {
|
||||
pub file: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AliasCommand {
|
||||
Read,
|
||||
Change,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AliasConfig {
|
||||
pub command: AliasCommand,
|
||||
pub query: String,
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
pub target: Option<String>,
|
||||
pub branch: Option<String>,
|
||||
pub format: Option<ReadOutputFormat>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OmnigraphConfig {
|
||||
#[serde(default)]
|
||||
pub project: ProjectConfig,
|
||||
#[serde(default)]
|
||||
pub targets: BTreeMap<String, TargetConfig>,
|
||||
#[serde(default)]
|
||||
pub server: ServerDefaults,
|
||||
#[serde(default)]
|
||||
pub auth: AuthDefaults,
|
||||
#[serde(default)]
|
||||
pub cli: CliDefaults,
|
||||
#[serde(default)]
|
||||
pub query: QueryDefaults,
|
||||
#[serde(default)]
|
||||
pub aliases: BTreeMap<String, AliasConfig>,
|
||||
#[serde(default)]
|
||||
pub policy: PolicySettings,
|
||||
#[serde(skip)]
|
||||
base_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for OmnigraphConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
project: ProjectConfig::default(),
|
||||
targets: BTreeMap::new(),
|
||||
server: ServerDefaults::default(),
|
||||
auth: AuthDefaults::default(),
|
||||
cli: CliDefaults::default(),
|
||||
query: QueryDefaults::default(),
|
||||
aliases: BTreeMap::new(),
|
||||
policy: PolicySettings::default(),
|
||||
base_dir: PathBuf::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OmnigraphConfig {
|
||||
pub fn base_dir(&self) -> &Path {
|
||||
&self.base_dir
|
||||
}
|
||||
|
||||
pub fn cli_branch(&self) -> &str {
|
||||
self.cli.branch.as_deref().unwrap_or("main")
|
||||
}
|
||||
|
||||
pub fn cli_output_format(&self) -> ReadOutputFormat {
|
||||
self.cli.output_format.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn table_max_column_width(&self) -> usize {
|
||||
self.cli.table_max_column_width.unwrap_or(80)
|
||||
}
|
||||
|
||||
pub fn table_cell_layout(&self) -> TableCellLayout {
|
||||
self.cli.table_cell_layout.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn cli_target_name(&self) -> Option<&str> {
|
||||
self.cli.target.as_deref()
|
||||
}
|
||||
|
||||
pub fn server_target_name(&self) -> Option<&str> {
|
||||
self.server.target.as_deref()
|
||||
}
|
||||
|
||||
pub fn server_bind(&self) -> &str {
|
||||
self.server.bind.as_deref().unwrap_or("127.0.0.1:8080")
|
||||
}
|
||||
|
||||
pub fn resolve_target_name<'a>(
|
||||
&self,
|
||||
explicit_uri: Option<&str>,
|
||||
explicit_target: Option<&'a str>,
|
||||
default_target: Option<&'a str>,
|
||||
) -> Option<&'a str> {
|
||||
explicit_target.or_else(|| {
|
||||
if explicit_uri.is_some() {
|
||||
None
|
||||
} else {
|
||||
default_target
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn target_bearer_token_env(
|
||||
&self,
|
||||
explicit_uri: Option<&str>,
|
||||
explicit_target: Option<&str>,
|
||||
default_target: Option<&str>,
|
||||
) -> Option<&str> {
|
||||
let target_name =
|
||||
self.resolve_target_name(explicit_uri, explicit_target, default_target)?;
|
||||
self.targets
|
||||
.get(target_name)
|
||||
.and_then(|target| target.bearer_token_env.as_deref())
|
||||
}
|
||||
|
||||
pub fn resolve_auth_env_file(&self) -> Option<PathBuf> {
|
||||
let path = self.auth.env_file.as_deref()?;
|
||||
let path = Path::new(path);
|
||||
Some(if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
self.base_dir.join(path)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_policy_file(&self) -> Option<PathBuf> {
|
||||
let path = self.policy.file.as_deref()?;
|
||||
let path = Path::new(path);
|
||||
Some(if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
self.base_dir.join(path)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_policy_tests_file(&self) -> Option<PathBuf> {
|
||||
let policy_file = self.resolve_policy_file()?;
|
||||
Some(policy_file.with_file_name("policy.tests.yaml"))
|
||||
}
|
||||
|
||||
pub fn alias(&self, name: &str) -> Result<&AliasConfig> {
|
||||
self.aliases
|
||||
.get(name)
|
||||
.ok_or_else(|| color_eyre::eyre::eyre!("alias '{}' not found", name))
|
||||
}
|
||||
|
||||
pub fn resolve_target_uri(
|
||||
&self,
|
||||
explicit_uri: Option<String>,
|
||||
explicit_target: Option<&str>,
|
||||
default_target: Option<&str>,
|
||||
) -> Result<String> {
|
||||
if let Some(uri) = explicit_uri {
|
||||
return Ok(uri);
|
||||
}
|
||||
|
||||
let target_name = explicit_target.or(default_target).ok_or_else(|| {
|
||||
color_eyre::eyre::eyre!("URI must be provided via <URI>, --target, or config")
|
||||
})?;
|
||||
let target = self.targets.get(target_name).ok_or_else(|| {
|
||||
color_eyre::eyre::eyre!(
|
||||
"target '{}' not found in {}",
|
||||
target_name,
|
||||
DEFAULT_CONFIG_FILE
|
||||
)
|
||||
})?;
|
||||
Ok(self.resolve_config_uri(&target.uri))
|
||||
}
|
||||
|
||||
pub fn resolve_query_path(&self, query: &Path) -> Result<PathBuf> {
|
||||
if query.is_absolute() {
|
||||
return Ok(query.to_path_buf());
|
||||
}
|
||||
|
||||
let direct = self.base_dir.join(query);
|
||||
if direct.exists() {
|
||||
return Ok(direct);
|
||||
}
|
||||
|
||||
for root in &self.query.roots {
|
||||
let candidate = self.base_dir.join(root).join(query);
|
||||
if candidate.exists() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
bail!("query file '{}' not found", query.display());
|
||||
}
|
||||
|
||||
fn resolve_config_uri(&self, value: &str) -> String {
|
||||
if value.contains("://") {
|
||||
return value.to_string();
|
||||
}
|
||||
|
||||
let path = Path::new(value);
|
||||
if path.is_absolute() {
|
||||
value.to_string()
|
||||
} else {
|
||||
self.base_dir.join(path).to_string_lossy().to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_config_path() -> PathBuf {
|
||||
PathBuf::from(DEFAULT_CONFIG_FILE)
|
||||
}
|
||||
|
||||
pub fn load_config(config_path: Option<&PathBuf>) -> Result<OmnigraphConfig> {
|
||||
load_config_in(&env::current_dir()?, config_path)
|
||||
}
|
||||
|
||||
fn load_config_in(cwd: &Path, config_path: Option<&PathBuf>) -> Result<OmnigraphConfig> {
|
||||
let explicit_path = config_path.cloned();
|
||||
let config_path = explicit_path.or_else(|| {
|
||||
let default_path = cwd.join(DEFAULT_CONFIG_FILE);
|
||||
default_path.exists().then_some(default_path)
|
||||
});
|
||||
|
||||
let mut config = if let Some(path) = &config_path {
|
||||
serde_yaml::from_str::<OmnigraphConfig>(&fs::read_to_string(path)?)?
|
||||
} else {
|
||||
OmnigraphConfig::default()
|
||||
};
|
||||
|
||||
config.base_dir = if let Some(path) = config_path {
|
||||
absolute_base_dir(cwd, &path)?
|
||||
} else {
|
||||
cwd.to_path_buf()
|
||||
};
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn absolute_base_dir(cwd: &Path, path: &Path) -> Result<PathBuf> {
|
||||
let path = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
cwd.join(path)
|
||||
};
|
||||
Ok(path
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| cwd.to_path_buf()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use tempfile::tempdir;
|
||||
|
||||
use super::{ReadOutputFormat, TableCellLayout, load_config_in};
|
||||
|
||||
#[test]
|
||||
fn load_config_reads_yaml_defaults_from_current_dir() {
|
||||
let temp = tempdir().unwrap();
|
||||
fs::write(
|
||||
temp.path().join("omnigraph.yaml"),
|
||||
r#"
|
||||
targets:
|
||||
local:
|
||||
uri: ./demo.omni
|
||||
bearer_token_env: DEMO_TOKEN
|
||||
auth:
|
||||
env_file: .env.omni
|
||||
cli:
|
||||
target: local
|
||||
branch: main
|
||||
output_format: kv
|
||||
table_max_column_width: 40
|
||||
table_cell_layout: wrap
|
||||
policy: {}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = load_config_in(temp.path(), None).unwrap();
|
||||
assert_eq!(config.cli_target_name(), Some("local"));
|
||||
assert_eq!(config.cli_branch(), "main");
|
||||
assert_eq!(config.cli_output_format(), ReadOutputFormat::Kv);
|
||||
assert_eq!(config.table_max_column_width(), 40);
|
||||
assert_eq!(config.table_cell_layout(), TableCellLayout::Wrap);
|
||||
assert_eq!(
|
||||
config.target_bearer_token_env(None, None, config.cli_target_name()),
|
||||
Some("DEMO_TOKEN")
|
||||
);
|
||||
assert_eq!(
|
||||
config.resolve_auth_env_file().unwrap(),
|
||||
temp.path().join(".env.omni")
|
||||
);
|
||||
assert_eq!(
|
||||
PathBuf::from(
|
||||
config
|
||||
.resolve_target_uri(None, None, config.cli_target_name())
|
||||
.unwrap()
|
||||
),
|
||||
temp.path().join("./demo.omni")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_does_not_walk_parent_directories() {
|
||||
let temp = tempdir().unwrap();
|
||||
let child = temp.path().join("child");
|
||||
fs::create_dir_all(&child).unwrap();
|
||||
fs::write(
|
||||
temp.path().join("omnigraph.yaml"),
|
||||
"targets:\n local:\n uri: ./demo.omni\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = load_config_in(&child, None).unwrap();
|
||||
assert!(config.targets.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_query_path_searches_config_roots() {
|
||||
let temp = tempdir().unwrap();
|
||||
fs::create_dir_all(temp.path().join("queries")).unwrap();
|
||||
fs::write(
|
||||
temp.path().join("omnigraph.yaml"),
|
||||
"query:\n roots:\n - queries\npolicy: {}\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
temp.path().join("queries").join("test.gq"),
|
||||
"query q { return {} }",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = load_config_in(temp.path(), None).unwrap();
|
||||
let resolved = config.resolve_query_path(Path::new("test.gq")).unwrap();
|
||||
assert_eq!(resolved, temp.path().join("queries").join("test.gq"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_query_path_prefers_config_base_dir_over_ambient_cwd() {
|
||||
let workspace = tempdir().unwrap();
|
||||
let config_dir = workspace.path().join("config");
|
||||
let ambient_dir = workspace.path().join("ambient");
|
||||
fs::create_dir_all(&config_dir).unwrap();
|
||||
fs::create_dir_all(&ambient_dir).unwrap();
|
||||
fs::write(config_dir.join("omnigraph.yaml"), "policy: {}\n").unwrap();
|
||||
fs::write(config_dir.join("local.gq"), "query local { return {} }").unwrap();
|
||||
fs::write(ambient_dir.join("local.gq"), "query ambient { return {} }").unwrap();
|
||||
|
||||
let config =
|
||||
load_config_in(&ambient_dir, Some(&config_dir.join("omnigraph.yaml"))).unwrap();
|
||||
let resolved = config.resolve_query_path(Path::new("local.gq")).unwrap();
|
||||
|
||||
assert_eq!(resolved, config_dir.join("local.gq"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_block_accepts_non_empty_mapping() {
|
||||
let temp = tempdir().unwrap();
|
||||
fs::write(
|
||||
temp.path().join("omnigraph.yaml"),
|
||||
"policy:\n file: ./policy.yaml\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = load_config_in(temp.path(), None).unwrap();
|
||||
assert_eq!(
|
||||
config.resolve_policy_file().unwrap(),
|
||||
temp.path().join("policy.yaml")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_auth_env_ignores_default_target_when_uri_is_explicit() {
|
||||
let temp = tempdir().unwrap();
|
||||
fs::write(
|
||||
temp.path().join("omnigraph.yaml"),
|
||||
r#"
|
||||
targets:
|
||||
demo:
|
||||
uri: https://example.com
|
||||
bearer_token_env: DEMO_TOKEN
|
||||
cli:
|
||||
target: demo
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config = load_config_in(temp.path(), None).unwrap();
|
||||
assert_eq!(
|
||||
config.target_bearer_token_env(
|
||||
Some("https://override.example.com"),
|
||||
None,
|
||||
config.cli_target_name()
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
config.target_bearer_token_env(
|
||||
Some("https://override.example.com"),
|
||||
Some("demo"),
|
||||
config.cli_target_name()
|
||||
),
|
||||
Some("DEMO_TOKEN")
|
||||
);
|
||||
}
|
||||
}
|
||||
1257
crates/omnigraph-server/src/lib.rs
Normal file
1257
crates/omnigraph-server/src/lib.rs
Normal file
File diff suppressed because it is too large
Load diff
30
crates/omnigraph-server/src/main.rs
Normal file
30
crates/omnigraph-server/src/main.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
use color_eyre::eyre::Result;
|
||||
use omnigraph_server::{ServerConfig, init_tracing, load_server_settings, serve};
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "omnigraph-server")]
|
||||
#[command(about = "HTTP server for the Omnigraph graph database")]
|
||||
struct Cli {
|
||||
/// Repo URI
|
||||
uri: Option<String>,
|
||||
#[arg(long)]
|
||||
target: Option<String>,
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
#[arg(long)]
|
||||
bind: Option<String>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
color_eyre::install()?;
|
||||
init_tracing();
|
||||
|
||||
let cli = Cli::parse();
|
||||
let settings: ServerConfig =
|
||||
load_server_settings(cli.config.as_ref(), cli.uri, cli.target, cli.bind)?;
|
||||
serve(settings).await
|
||||
}
|
||||
812
crates/omnigraph-server/src/policy.rs
Normal file
812
crates/omnigraph-server/src/policy.rs
Normal file
|
|
@ -0,0 +1,812 @@
|
|||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
|
||||
use cedar_policy::{
|
||||
Authorizer, Context, Decision, Entities, Entity, EntityId, EntityTypeName, EntityUid, Policy,
|
||||
PolicyId, PolicySet, Request, Schema, ValidationMode, Validator,
|
||||
};
|
||||
use clap::ValueEnum;
|
||||
use color_eyre::eyre::{Result, bail, eyre};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Serialize, Deserialize, ValueEnum)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PolicyAction {
|
||||
Read,
|
||||
Export,
|
||||
Change,
|
||||
BranchCreate,
|
||||
BranchDelete,
|
||||
BranchMerge,
|
||||
RunPublish,
|
||||
RunAbort,
|
||||
Admin,
|
||||
}
|
||||
|
||||
impl PolicyAction {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Read => "read",
|
||||
Self::Export => "export",
|
||||
Self::Change => "change",
|
||||
Self::BranchCreate => "branch_create",
|
||||
Self::BranchDelete => "branch_delete",
|
||||
Self::BranchMerge => "branch_merge",
|
||||
Self::RunPublish => "run_publish",
|
||||
Self::RunAbort => "run_abort",
|
||||
Self::Admin => "admin",
|
||||
}
|
||||
}
|
||||
|
||||
fn uses_branch_scope(self) -> bool {
|
||||
matches!(self, Self::Read | Self::Export | Self::Change)
|
||||
}
|
||||
|
||||
fn uses_target_branch_scope(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::BranchCreate
|
||||
| Self::BranchDelete
|
||||
| Self::BranchMerge
|
||||
| Self::RunPublish
|
||||
| Self::RunAbort
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PolicyAction {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for PolicyAction {
|
||||
type Err = color_eyre::eyre::Error;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self> {
|
||||
match value.trim() {
|
||||
"read" => Ok(Self::Read),
|
||||
"export" => Ok(Self::Export),
|
||||
"change" => Ok(Self::Change),
|
||||
"branch_create" => Ok(Self::BranchCreate),
|
||||
"branch_delete" => Ok(Self::BranchDelete),
|
||||
"branch_merge" => Ok(Self::BranchMerge),
|
||||
"run_publish" => Ok(Self::RunPublish),
|
||||
"run_abort" => Ok(Self::RunAbort),
|
||||
"admin" => Ok(Self::Admin),
|
||||
other => bail!("unknown policy action '{other}'"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PolicyBranchScope {
|
||||
Any,
|
||||
Protected,
|
||||
Unprotected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PolicyActorSelector {
|
||||
pub group: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PolicyAllowRule {
|
||||
pub actors: PolicyActorSelector,
|
||||
pub actions: Vec<PolicyAction>,
|
||||
pub branch_scope: Option<PolicyBranchScope>,
|
||||
pub target_branch_scope: Option<PolicyBranchScope>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PolicyRule {
|
||||
pub id: String,
|
||||
pub allow: PolicyAllowRule,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PolicyConfig {
|
||||
pub version: u32,
|
||||
#[serde(default)]
|
||||
pub groups: BTreeMap<String, Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub protected_branches: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub rules: Vec<PolicyRule>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PolicyTestConfig {
|
||||
pub version: u32,
|
||||
#[serde(default)]
|
||||
pub cases: Vec<PolicyTestCase>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PolicyTestCase {
|
||||
pub id: String,
|
||||
pub actor: String,
|
||||
pub action: PolicyAction,
|
||||
pub branch: Option<String>,
|
||||
pub target_branch: Option<String>,
|
||||
pub expect: PolicyExpectation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PolicyExpectation {
|
||||
Allow,
|
||||
Deny,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PolicyRequest {
|
||||
pub actor_id: String,
|
||||
pub action: PolicyAction,
|
||||
pub branch: Option<String>,
|
||||
pub target_branch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PolicyDecision {
|
||||
pub allowed: bool,
|
||||
pub matched_rule_id: Option<String>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub struct PolicyCompiler;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PolicyEngine {
|
||||
repo_id: String,
|
||||
protected_branches: BTreeSet<String>,
|
||||
known_actors: BTreeSet<String>,
|
||||
schema: Schema,
|
||||
entities: Entities,
|
||||
policies: PolicySet,
|
||||
policy_to_rule: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl PolicyConfig {
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
let config: Self = serde_yaml::from_str(&fs::read_to_string(path)?)?;
|
||||
config.validate()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.version != 1 {
|
||||
bail!("policy version must be 1");
|
||||
}
|
||||
|
||||
for (group, members) in &self.groups {
|
||||
if group.trim().is_empty() {
|
||||
bail!("policy group names must not be blank");
|
||||
}
|
||||
if members.is_empty() {
|
||||
bail!("policy group '{group}' must not be empty");
|
||||
}
|
||||
for actor in members {
|
||||
if actor.trim().is_empty() {
|
||||
bail!("policy group '{group}' contains a blank actor id");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for branch in &self.protected_branches {
|
||||
if branch.trim().is_empty() {
|
||||
bail!("protected branch names must not be blank");
|
||||
}
|
||||
}
|
||||
|
||||
let mut seen_rule_ids = HashSet::new();
|
||||
for rule in &self.rules {
|
||||
if rule.id.trim().is_empty() {
|
||||
bail!("policy rule ids must not be blank");
|
||||
}
|
||||
if !seen_rule_ids.insert(rule.id.clone()) {
|
||||
bail!("duplicate policy rule id '{}'", rule.id);
|
||||
}
|
||||
if rule.allow.actors.group.trim().is_empty() {
|
||||
bail!("policy rule '{}' must reference a non-blank group", rule.id);
|
||||
}
|
||||
if !self.groups.contains_key(rule.allow.actors.group.as_str()) {
|
||||
bail!(
|
||||
"policy rule '{}' references unknown group '{}'",
|
||||
rule.id,
|
||||
rule.allow.actors.group
|
||||
);
|
||||
}
|
||||
if rule.allow.actions.is_empty() {
|
||||
bail!("policy rule '{}' must include at least one action", rule.id);
|
||||
}
|
||||
if rule.allow.branch_scope.is_some() && rule.allow.target_branch_scope.is_some() {
|
||||
bail!(
|
||||
"policy rule '{}' may specify branch_scope or target_branch_scope, not both",
|
||||
rule.id
|
||||
);
|
||||
}
|
||||
if let Some(_) = rule.allow.branch_scope {
|
||||
for action in &rule.allow.actions {
|
||||
if !action.uses_branch_scope() {
|
||||
bail!(
|
||||
"policy rule '{}' uses branch_scope with unsupported action '{}'",
|
||||
rule.id,
|
||||
action
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(_) = rule.allow.target_branch_scope {
|
||||
for action in &rule.allow.actions {
|
||||
if !action.uses_target_branch_scope() {
|
||||
bail!(
|
||||
"policy rule '{}' uses target_branch_scope with unsupported action '{}'",
|
||||
rule.id,
|
||||
action
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl PolicyTestConfig {
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
let config: Self = serde_yaml::from_str(&fs::read_to_string(path)?)?;
|
||||
if config.version != 1 {
|
||||
bail!("policy test version must be 1");
|
||||
}
|
||||
let mut seen = HashSet::new();
|
||||
for case in &config.cases {
|
||||
if case.id.trim().is_empty() {
|
||||
bail!("policy test case ids must not be blank");
|
||||
}
|
||||
if !seen.insert(case.id.clone()) {
|
||||
bail!("duplicate policy test case id '{}'", case.id);
|
||||
}
|
||||
if case.actor.trim().is_empty() {
|
||||
bail!("policy test case '{}' must not use a blank actor", case.id);
|
||||
}
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
impl PolicyCompiler {
|
||||
pub fn compile(config: &PolicyConfig, repo_id: &str) -> Result<PolicyEngine> {
|
||||
config.validate()?;
|
||||
let (schema, schema_warnings) = Schema::from_cedarschema_str(policy_schema_source())?;
|
||||
let schema_warnings = schema_warnings
|
||||
.map(|warning| warning.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
if !schema_warnings.is_empty() {
|
||||
bail!("policy schema warnings:\n{}", schema_warnings.join("\n"));
|
||||
}
|
||||
let entities = compile_entities(config, repo_id, &schema)?;
|
||||
let (policies, policy_to_rule) = compile_policies(config, repo_id)?;
|
||||
let validator = Validator::new(schema.clone());
|
||||
let validation = validator.validate(&policies, ValidationMode::Strict);
|
||||
let errors = validation
|
||||
.validation_errors()
|
||||
.map(|err| err.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
if !errors.is_empty() {
|
||||
bail!("policy validation failed:\n{}", errors.join("\n"));
|
||||
}
|
||||
|
||||
let known_actors = config
|
||||
.groups
|
||||
.values()
|
||||
.flat_map(|members| members.iter().cloned())
|
||||
.collect();
|
||||
Ok(PolicyEngine {
|
||||
repo_id: repo_id.to_string(),
|
||||
protected_branches: config.protected_branches.iter().cloned().collect(),
|
||||
known_actors,
|
||||
schema,
|
||||
entities,
|
||||
policies,
|
||||
policy_to_rule,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl PolicyEngine {
|
||||
pub fn load(path: &Path, repo_id: &str) -> Result<Self> {
|
||||
let config = PolicyConfig::load(path)?;
|
||||
PolicyCompiler::compile(&config, repo_id)
|
||||
}
|
||||
|
||||
pub fn authorize(&self, request: &PolicyRequest) -> Result<PolicyDecision> {
|
||||
if !self.known_actors.contains(request.actor_id.as_str()) {
|
||||
return Ok(self.deny(
|
||||
request,
|
||||
None,
|
||||
format!(
|
||||
"policy denied action '{}' for unknown actor '{}'",
|
||||
request.action, request.actor_id
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let principal = entity_uid("Actor", &request.actor_id)?;
|
||||
let action = entity_uid("Action", request.action.as_str())?;
|
||||
let resource = entity_uid("Repo", &self.repo_id)?;
|
||||
let context_value = json!({
|
||||
"has_branch": request.branch.is_some(),
|
||||
"branch": request.branch.clone().unwrap_or_default(),
|
||||
"has_target_branch": request.target_branch.is_some(),
|
||||
"target_branch": request.target_branch.clone().unwrap_or_default(),
|
||||
"branch_is_protected": request.branch.as_ref().is_some_and(|branch| self.protected_branches.contains(branch)),
|
||||
"target_branch_is_protected": request.target_branch.as_ref().is_some_and(|branch| self.protected_branches.contains(branch)),
|
||||
});
|
||||
let context = Context::from_json_value(context_value, Some((&self.schema, &action)))?;
|
||||
let cedar_request = Request::new(principal, action, resource, context, Some(&self.schema))?;
|
||||
let response =
|
||||
Authorizer::new().is_authorized(&cedar_request, &self.policies, &self.entities);
|
||||
let errors = response
|
||||
.diagnostics()
|
||||
.errors()
|
||||
.map(|err| err.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
if !errors.is_empty() {
|
||||
bail!("policy evaluation failed:\n{}", errors.join("\n"));
|
||||
}
|
||||
|
||||
let matched_rule_id = response
|
||||
.diagnostics()
|
||||
.reason()
|
||||
.filter_map(|policy_id| {
|
||||
let key: &str = policy_id.as_ref();
|
||||
self.policy_to_rule.get(key).cloned()
|
||||
})
|
||||
.min();
|
||||
|
||||
Ok(match response.decision() {
|
||||
Decision::Allow => PolicyDecision {
|
||||
allowed: true,
|
||||
matched_rule_id: matched_rule_id.clone(),
|
||||
message: format!(
|
||||
"policy allowed action '{}' for actor '{}'",
|
||||
request.action, request.actor_id
|
||||
),
|
||||
},
|
||||
Decision::Deny => {
|
||||
let message = format!(
|
||||
"policy denied action '{}'{}{} for actor '{}'",
|
||||
request.action,
|
||||
request
|
||||
.branch
|
||||
.as_deref()
|
||||
.map(|branch| format!(" on branch '{}'", branch))
|
||||
.unwrap_or_default(),
|
||||
request
|
||||
.target_branch
|
||||
.as_deref()
|
||||
.map(|branch| format!(" targeting branch '{}'", branch))
|
||||
.unwrap_or_default(),
|
||||
request.actor_id
|
||||
);
|
||||
self.deny(request, matched_rule_id, message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_request(&self, request: &PolicyRequest) -> Result<()> {
|
||||
let _ = self.authorize(request)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_tests(&self, tests: &PolicyTestConfig) -> Result<()> {
|
||||
if tests.version != 1 {
|
||||
bail!("policy test version must be 1");
|
||||
}
|
||||
let mut failures = Vec::new();
|
||||
for case in &tests.cases {
|
||||
let decision = self.authorize(&PolicyRequest {
|
||||
actor_id: case.actor.clone(),
|
||||
action: case.action,
|
||||
branch: case.branch.clone(),
|
||||
target_branch: case.target_branch.clone(),
|
||||
})?;
|
||||
let expected_allowed = matches!(case.expect, PolicyExpectation::Allow);
|
||||
if decision.allowed != expected_allowed {
|
||||
failures.push(format!(
|
||||
"{}: expected {:?} but got {}",
|
||||
case.id,
|
||||
case.expect,
|
||||
if decision.allowed { "allow" } else { "deny" }
|
||||
));
|
||||
}
|
||||
}
|
||||
if failures.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
bail!("policy tests failed:\n{}", failures.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn known_actor_count(&self) -> usize {
|
||||
self.known_actors.len()
|
||||
}
|
||||
|
||||
fn deny(
|
||||
&self,
|
||||
_request: &PolicyRequest,
|
||||
matched_rule_id: Option<String>,
|
||||
message: String,
|
||||
) -> PolicyDecision {
|
||||
PolicyDecision {
|
||||
allowed: false,
|
||||
matched_rule_id,
|
||||
message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compile_entities(config: &PolicyConfig, repo_id: &str, schema: &Schema) -> Result<Entities> {
|
||||
let mut group_entities = Vec::new();
|
||||
for group in config.groups.keys() {
|
||||
group_entities.push(Entity::new(
|
||||
entity_uid("Group", group)?,
|
||||
HashMap::new(),
|
||||
HashSet::<EntityUid>::new(),
|
||||
)?);
|
||||
}
|
||||
|
||||
let mut actor_groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
|
||||
for (group, members) in &config.groups {
|
||||
for actor in members {
|
||||
actor_groups
|
||||
.entry(actor.clone())
|
||||
.or_default()
|
||||
.insert(group.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let mut actor_entities = Vec::new();
|
||||
for (actor, groups) in actor_groups {
|
||||
let parents = groups
|
||||
.iter()
|
||||
.map(|group| entity_uid("Group", group))
|
||||
.collect::<Result<HashSet<_>>>()?;
|
||||
actor_entities.push(Entity::new(
|
||||
entity_uid("Actor", &actor)?,
|
||||
HashMap::new(),
|
||||
parents,
|
||||
)?);
|
||||
}
|
||||
|
||||
let repo_entity = Entity::new(
|
||||
entity_uid("Repo", repo_id)?,
|
||||
HashMap::new(),
|
||||
HashSet::<EntityUid>::new(),
|
||||
)?;
|
||||
|
||||
let mut entities = Vec::new();
|
||||
entities.extend(group_entities);
|
||||
entities.extend(actor_entities);
|
||||
entities.push(repo_entity);
|
||||
Ok(Entities::from_entities(entities, Some(schema))?)
|
||||
}
|
||||
|
||||
fn compile_policies(
|
||||
config: &PolicyConfig,
|
||||
repo_id: &str,
|
||||
) -> Result<(PolicySet, HashMap<String, String>)> {
|
||||
let mut policies = Vec::new();
|
||||
let mut policy_to_rule = HashMap::new();
|
||||
|
||||
for rule in &config.rules {
|
||||
for action in &rule.allow.actions {
|
||||
let policy_id = PolicyId::new(format!("{}:{}", rule.id, action.as_str()));
|
||||
let source = compile_policy_source(rule, action, repo_id);
|
||||
let policy = Policy::parse(Some(policy_id.clone()), source.as_str())?;
|
||||
policy_to_rule.insert(policy_id.to_string(), rule.id.clone());
|
||||
policies.push(policy);
|
||||
}
|
||||
}
|
||||
|
||||
Ok((PolicySet::from_policies(policies)?, policy_to_rule))
|
||||
}
|
||||
|
||||
fn compile_policy_source(rule: &PolicyRule, action: &PolicyAction, repo_id: &str) -> String {
|
||||
let mut conditions = Vec::new();
|
||||
if let Some(scope) = rule.allow.branch_scope {
|
||||
conditions.push(branch_scope_condition(scope));
|
||||
}
|
||||
if let Some(scope) = rule.allow.target_branch_scope {
|
||||
conditions.push(target_branch_scope_condition(scope));
|
||||
}
|
||||
|
||||
let when = if conditions.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("\nwhen {{ {} }}", conditions.join(" && "))
|
||||
};
|
||||
|
||||
format!(
|
||||
r#"permit (
|
||||
principal in Omnigraph::Group::{group},
|
||||
action == Omnigraph::Action::{action},
|
||||
resource == Omnigraph::Repo::{repo}
|
||||
){when};"#,
|
||||
group = cedar_literal(&rule.allow.actors.group),
|
||||
action = cedar_literal(action.as_str()),
|
||||
repo = cedar_literal(repo_id),
|
||||
when = when,
|
||||
)
|
||||
}
|
||||
|
||||
fn branch_scope_condition(scope: PolicyBranchScope) -> String {
|
||||
match scope {
|
||||
PolicyBranchScope::Any => "true".to_string(),
|
||||
PolicyBranchScope::Protected => {
|
||||
"context.has_branch && context.branch_is_protected".to_string()
|
||||
}
|
||||
PolicyBranchScope::Unprotected => {
|
||||
"context.has_branch && context.branch_is_protected == false".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn target_branch_scope_condition(scope: PolicyBranchScope) -> String {
|
||||
match scope {
|
||||
PolicyBranchScope::Any => "true".to_string(),
|
||||
PolicyBranchScope::Protected => {
|
||||
"context.has_target_branch && context.target_branch_is_protected".to_string()
|
||||
}
|
||||
PolicyBranchScope::Unprotected => {
|
||||
"context.has_target_branch && context.target_branch_is_protected == false".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn policy_schema_source() -> &'static str {
|
||||
r#"
|
||||
namespace Omnigraph {
|
||||
type RequestContext = {
|
||||
has_branch: Bool,
|
||||
branch: String,
|
||||
has_target_branch: Bool,
|
||||
target_branch: String,
|
||||
branch_is_protected: Bool,
|
||||
target_branch_is_protected: Bool,
|
||||
};
|
||||
|
||||
entity Actor in [Group];
|
||||
entity Group;
|
||||
entity Repo;
|
||||
|
||||
action "read" appliesTo { principal: Actor, resource: Repo, context: RequestContext };
|
||||
action "export" appliesTo { principal: Actor, resource: Repo, context: RequestContext };
|
||||
action "change" appliesTo { principal: Actor, resource: Repo, context: RequestContext };
|
||||
action "branch_create" appliesTo { principal: Actor, resource: Repo, context: RequestContext };
|
||||
action "branch_delete" appliesTo { principal: Actor, resource: Repo, context: RequestContext };
|
||||
action "branch_merge" appliesTo { principal: Actor, resource: Repo, context: RequestContext };
|
||||
action "run_publish" appliesTo { principal: Actor, resource: Repo, context: RequestContext };
|
||||
action "run_abort" appliesTo { principal: Actor, resource: Repo, context: RequestContext };
|
||||
action "admin" appliesTo { principal: Actor, resource: Repo, context: RequestContext };
|
||||
}
|
||||
"#
|
||||
}
|
||||
|
||||
fn entity_uid(entity_type: &str, id: &str) -> Result<EntityUid> {
|
||||
let typename = EntityTypeName::from_str(&format!("Omnigraph::{entity_type}"))?;
|
||||
let entity_id = EntityId::from_str(id).map_err(|err| eyre!(err.to_string()))?;
|
||||
Ok(EntityUid::from_type_name_and_id(typename, entity_id))
|
||||
}
|
||||
|
||||
fn cedar_literal(value: &str) -> String {
|
||||
serde_json::to_string(value).expect("string literal should serialize")
|
||||
}
|
||||
|
||||
impl PolicyRequest {
|
||||
pub fn actor_id(&self) -> &str {
|
||||
&self.actor_id
|
||||
}
|
||||
|
||||
pub fn action(&self) -> PolicyAction {
|
||||
self.action
|
||||
}
|
||||
|
||||
pub fn branch(&self) -> Option<&str> {
|
||||
self.branch.as_deref()
|
||||
}
|
||||
|
||||
pub fn target_branch(&self) -> Option<&str> {
|
||||
self.target_branch.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
PolicyAction, PolicyCompiler, PolicyConfig, PolicyExpectation, PolicyRequest,
|
||||
PolicyTestCase, PolicyTestConfig,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_rule_ids() {
|
||||
let policy: PolicyConfig = serde_yaml::from_str(
|
||||
r#"
|
||||
version: 1
|
||||
groups:
|
||||
team: [act-andrew]
|
||||
rules:
|
||||
- id: same
|
||||
allow:
|
||||
actors: { group: team }
|
||||
actions: [read]
|
||||
branch_scope: any
|
||||
- id: same
|
||||
allow:
|
||||
actors: { group: team }
|
||||
actions: [export]
|
||||
branch_scope: any
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = policy.validate().unwrap_err();
|
||||
assert!(err.to_string().contains("duplicate policy rule id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_group_references() {
|
||||
let policy: PolicyConfig = serde_yaml::from_str(
|
||||
r#"
|
||||
version: 1
|
||||
groups:
|
||||
team: [act-andrew]
|
||||
rules:
|
||||
- id: bad
|
||||
allow:
|
||||
actors: { group: admins }
|
||||
actions: [read]
|
||||
branch_scope: any
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = policy.validate().unwrap_err();
|
||||
assert!(err.to_string().contains("references unknown group"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_scope_action_combinations() {
|
||||
let policy: PolicyConfig = serde_yaml::from_str(
|
||||
r#"
|
||||
version: 1
|
||||
groups:
|
||||
team: [act-andrew]
|
||||
rules:
|
||||
- id: bad
|
||||
allow:
|
||||
actors: { group: team }
|
||||
actions: [branch_merge]
|
||||
branch_scope: protected
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = policy.validate().unwrap_err();
|
||||
assert!(err.to_string().contains("unsupported action"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compiles_and_authorizes_branch_and_target_rules() {
|
||||
let policy: PolicyConfig = serde_yaml::from_str(
|
||||
r#"
|
||||
version: 1
|
||||
groups:
|
||||
team: [act-andrew, act-bruno]
|
||||
admins: [act-andrew]
|
||||
protected_branches: [main]
|
||||
rules:
|
||||
- id: team-read
|
||||
allow:
|
||||
actors: { group: team }
|
||||
actions: [read, export]
|
||||
branch_scope: any
|
||||
- id: team-write
|
||||
allow:
|
||||
actors: { group: team }
|
||||
actions: [change]
|
||||
branch_scope: unprotected
|
||||
- id: admins-promote
|
||||
allow:
|
||||
actors: { group: admins }
|
||||
actions: [branch_delete, branch_merge, run_publish]
|
||||
target_branch_scope: protected
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let engine = PolicyCompiler::compile(&policy, "repo").unwrap();
|
||||
let allow = engine
|
||||
.authorize(&PolicyRequest {
|
||||
actor_id: "act-bruno".to_string(),
|
||||
action: PolicyAction::Change,
|
||||
branch: Some("feature".to_string()),
|
||||
target_branch: None,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(allow.allowed);
|
||||
assert_eq!(allow.matched_rule_id.as_deref(), Some("team-write"));
|
||||
|
||||
let deny = engine
|
||||
.authorize(&PolicyRequest {
|
||||
actor_id: "act-bruno".to_string(),
|
||||
action: PolicyAction::BranchDelete,
|
||||
branch: None,
|
||||
target_branch: Some("main".to_string()),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!deny.allowed);
|
||||
|
||||
let admin = engine
|
||||
.authorize(&PolicyRequest {
|
||||
actor_id: "act-andrew".to_string(),
|
||||
action: PolicyAction::BranchDelete,
|
||||
branch: None,
|
||||
target_branch: Some("main".to_string()),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(admin.allowed);
|
||||
assert_eq!(admin.matched_rule_id.as_deref(), Some("admins-promote"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn policy_tests_enforce_expected_outcomes() {
|
||||
let policy: PolicyConfig = serde_yaml::from_str(
|
||||
r#"
|
||||
version: 1
|
||||
groups:
|
||||
team: [act-andrew]
|
||||
protected_branches: [main]
|
||||
rules:
|
||||
- id: team-read
|
||||
allow:
|
||||
actors: { group: team }
|
||||
actions: [read]
|
||||
branch_scope: any
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let engine = PolicyCompiler::compile(&policy, "repo").unwrap();
|
||||
let tests = PolicyTestConfig {
|
||||
version: 1,
|
||||
cases: vec![
|
||||
PolicyTestCase {
|
||||
id: "allow-read".to_string(),
|
||||
actor: "act-andrew".to_string(),
|
||||
action: PolicyAction::Read,
|
||||
branch: Some("main".to_string()),
|
||||
target_branch: None,
|
||||
expect: PolicyExpectation::Allow,
|
||||
},
|
||||
PolicyTestCase {
|
||||
id: "deny-change".to_string(),
|
||||
actor: "act-andrew".to_string(),
|
||||
action: PolicyAction::Change,
|
||||
branch: Some("main".to_string()),
|
||||
target_branch: None,
|
||||
expect: PolicyExpectation::Deny,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
engine.run_tests(&tests).unwrap();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue