From a0b7b14be2d189fc7aad558caaee2bd9498f59b8 Mon Sep 17 00:00:00 2001 From: lmtr0 Date: Wed, 8 Jul 2026 17:32:28 -0300 Subject: [PATCH] Add authorization and hosting policy hooks --- grit-lib-server/src/error.rs | 14 + grit-lib-server/src/lib.rs | 6 + grit-lib-server/src/policy.rs | 444 +++++++++++++++++++ grit-lib-server/src/protocol/receive_pack.rs | 217 +++++---- grit-lib-server/src/repository.rs | 35 +- grit-lib-server/tests/receive_pack.rs | 281 +++++++++++- 6 files changed, 891 insertions(+), 106 deletions(-) create mode 100644 grit-lib-server/src/policy.rs diff --git a/grit-lib-server/src/error.rs b/grit-lib-server/src/error.rs index ffb71306c..df4b5c961 100644 --- a/grit-lib-server/src/error.rs +++ b/grit-lib-server/src/error.rs @@ -53,6 +53,20 @@ pub enum Error { /// Policy-provided reason. reason: String, }, + /// A repository authorization provider denied access. + #[error("authorization denied for {actor} on {tenant}/{repository} {permission}: {reason}")] + AuthorizationDenied { + /// Tenant whose repository was accessed. + tenant: String, + /// Repository that was accessed. + repository: String, + /// Actor that requested access. + actor: String, + /// Permission that was denied. + permission: &'static str, + /// Provider-supplied reason. + reason: String, + }, /// A requested object was not found. #[error("object not found: {0}")] ObjectNotFound(String), diff --git a/grit-lib-server/src/lib.rs b/grit-lib-server/src/lib.rs index c1aacb484..4c6becc6e 100644 --- a/grit-lib-server/src/lib.rs +++ b/grit-lib-server/src/lib.rs @@ -14,6 +14,7 @@ pub mod layered; pub mod memory; #[cfg(feature = "nats")] pub mod nats_invalidation; +pub mod policy; pub mod protocol; #[cfg(feature = "redis")] pub mod redis_cache; @@ -36,6 +37,11 @@ pub mod prelude { pub use crate::layered::LayeredCache; #[cfg(feature = "nats")] pub use crate::nats_invalidation::{NatsInvalidationPublisher, NatsInvalidationSubscriber}; + pub use crate::policy::{ + AuditEvent, AuditOutcome, AuditSink, AuthorizationContext, AuthorizationProvider, + NoAuthorization, NoopAuditSink, PolicyActor, PolicyDecision, PolicyRefUpdate, + RefUpdatePolicyContext, RepositoryPermission, RepositoryPolicy, + }; pub use crate::protocol::receive_pack::{ AllowAllPushPolicy, ProtectedRefPolicy, PushCommandKind, PushCommandStatus, PushPlan, PushPolicy, PushPolicyContext, QuarantinedObject, ReceivePackCapability, diff --git a/grit-lib-server/src/policy.rs b/grit-lib-server/src/policy.rs new file mode 100644 index 000000000..bb423b38d --- /dev/null +++ b/grit-lib-server/src/policy.rs @@ -0,0 +1,444 @@ +//! Authorization, repository policy hooks, and audit integration for hosted writes. + +use async_trait::async_trait; +use grit_lib::objects::ObjectId; +use time::OffsetDateTime; + +use crate::error::{Error, Result}; +use crate::ids::{RepositoryId, TenantId}; +use crate::protocol::receive_pack::PushCommandKind; + +/// Actor identity supplied by the hosting platform for policy and reflog decisions. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PolicyActor { + /// Stable platform-neutral actor identifier. + pub id: String, + /// Git identity line written to reflogs for accepted writes. + pub reflog_identity: String, +} + +impl PolicyActor { + /// Create an actor identity from a stable `id` and Git `reflog_identity`. + /// + /// `id` is intended for authorization and audit integrations. `reflog_identity` is the + /// already-formatted identity line written to reflogs after accepted ref updates. + #[must_use] + pub fn new(id: impl Into, reflog_identity: impl Into) -> Self { + Self { + id: id.into(), + reflog_identity: reflog_identity.into(), + } + } +} + +/// Repository-level permission being checked by an authorization provider. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum RepositoryPermission { + /// Permission to push objects and update refs. + Write, +} + +impl RepositoryPermission { + /// Return a stable permission label for audit records and typed errors. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Write => "write", + } + } +} + +/// Context passed to a tenant or repository authorization provider. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthorizationContext { + /// Tenant that owns the repository. + pub tenant: TenantId, + /// Repository being accessed. + pub repository: RepositoryId, + /// Actor requesting access. + pub actor: PolicyActor, + /// Permission being checked. + pub permission: RepositoryPermission, +} + +/// Allow or deny decision returned by authorization and policy hooks. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PolicyDecision { + /// The operation may proceed. + Allow, + /// The operation is rejected with a platform-readable reason. + Deny { + /// Human-readable reason suitable for logs and protocol error mapping. + reason: String, + }, +} + +impl PolicyDecision { + /// Return an allow decision. + #[must_use] + pub fn allow() -> Self { + Self::Allow + } + + /// Return a deny decision with `reason`. + #[must_use] + pub fn deny(reason: impl Into) -> Self { + Self::Deny { + reason: reason.into(), + } + } + + /// Convert a ref-scoped push decision into a typed result. + /// + /// # Errors + /// + /// Returns [`Error::PushPolicyRejected`] when the decision denies the update. + pub fn into_push_result(self, refname: impl Into) -> Result<()> { + match self { + Self::Allow => Ok(()), + Self::Deny { reason } => Err(Error::PushPolicyRejected { + refname: refname.into(), + reason, + }), + } + } +} + +/// Authorization provider supplied by an embedding hosting platform. +#[async_trait] +pub trait AuthorizationProvider: Send + Sync { + /// Check whether `context.actor` has `context.permission` for the repository. + /// + /// # Errors + /// + /// Returns backend or integration errors from the provider. + async fn check(&self, context: &AuthorizationContext) -> Result; +} + +/// Authorization provider that permits all requests. +#[derive(Clone, Copy, Debug, Default)] +pub struct NoAuthorization; + +#[async_trait] +impl AuthorizationProvider for NoAuthorization { + async fn check(&self, _context: &AuthorizationContext) -> Result { + Ok(PolicyDecision::Allow) + } +} + +/// One ref update included in a push policy or audit context. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PolicyRefUpdate { + /// Ref being updated. + pub refname: String, + /// Old object id from the client command. + pub old_oid: ObjectId, + /// New object id from the client command. + pub new_oid: ObjectId, + /// Derived command kind. + pub kind: PushCommandKind, +} + +impl PolicyRefUpdate { + /// Create a ref update policy value. + #[must_use] + pub fn new( + refname: impl Into, + old_oid: ObjectId, + new_oid: ObjectId, + kind: PushCommandKind, + ) -> Self { + Self { + refname: refname.into(), + old_oid, + new_oid, + kind, + } + } +} + +/// Batch context passed to pre-receive and post-receive hooks. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PushPolicyContext { + /// Tenant that owns the repository. + pub tenant: TenantId, + /// Repository being pushed to. + pub repository: RepositoryId, + /// Actor performing the push. + pub actor: PolicyActor, + /// Ref updates requested by the push. + pub updates: Vec, + /// Commit objects introduced by the push pack. + pub pushed_commits: Vec, +} + +/// Per-ref context passed to update hooks. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RefUpdatePolicyContext { + /// Tenant that owns the repository. + pub tenant: TenantId, + /// Repository being pushed to. + pub repository: RepositoryId, + /// Actor performing the push. + pub actor: PolicyActor, + /// Ref update being checked. + pub update: PolicyRefUpdate, + /// Commit objects introduced by the push pack. + pub pushed_commits: Vec, +} + +/// Receive-pack policy hooks for a hosted repository. +#[async_trait] +pub trait PushPolicy: Send + Sync { + /// Run a pre-receive style check over the full push. + /// + /// # Errors + /// + /// Returns backend or integration errors from the policy implementation. + async fn pre_receive(&self, _context: &PushPolicyContext) -> Result { + Ok(PolicyDecision::Allow) + } + + /// Run an update style check for one ref update. + /// + /// # Errors + /// + /// Returns backend or integration errors from the policy implementation. + async fn update(&self, _context: &RefUpdatePolicyContext) -> Result { + Ok(PolicyDecision::Allow) + } + + /// Run a post-receive style hook after refs have been accepted. + /// + /// Post-receive hooks observe accepted writes and cannot reject already-applied ref updates. + /// + /// # Errors + /// + /// Returns backend or integration errors from the policy implementation. + async fn post_receive(&self, _context: &PushPolicyContext) -> Result<()> { + Ok(()) + } +} + +/// Push policy that accepts every hook decision. +#[derive(Clone, Copy, Debug, Default)] +pub struct AllowAllPushPolicy; + +#[async_trait] +impl PushPolicy for AllowAllPushPolicy {} + +/// Repository policy that combines authorization with push hooks. +#[derive(Clone, Debug)] +pub struct RepositoryPolicy { + authorization: A, + push_policy: P, +} + +impl RepositoryPolicy { + /// Create a repository policy from an `authorization` provider and `push_policy` hooks. + #[must_use] + pub fn new(authorization: A, push_policy: P) -> Self { + Self { + authorization, + push_policy, + } + } +} + +#[async_trait] +impl PushPolicy for RepositoryPolicy +where + A: AuthorizationProvider, + P: PushPolicy, +{ + async fn pre_receive(&self, context: &PushPolicyContext) -> Result { + let auth_context = AuthorizationContext { + tenant: context.tenant.clone(), + repository: context.repository.clone(), + actor: context.actor.clone(), + permission: RepositoryPermission::Write, + }; + match self.authorization.check(&auth_context).await? { + PolicyDecision::Allow => self.push_policy.pre_receive(context).await, + PolicyDecision::Deny { reason } => Err(Error::AuthorizationDenied { + tenant: auth_context.tenant.to_string(), + repository: auth_context.repository.to_string(), + actor: auth_context.actor.id, + permission: auth_context.permission.as_str(), + reason, + }), + } + } + + async fn update(&self, context: &RefUpdatePolicyContext) -> Result { + self.push_policy.update(context).await + } + + async fn post_receive(&self, context: &PushPolicyContext) -> Result<()> { + self.push_policy.post_receive(context).await + } +} + +/// Simple protected-ref policy based on exact refs and prefixes. +#[derive(Clone, Debug, Default)] +pub struct ProtectedRefPolicy { + protected_refs: Vec, +} + +#[derive(Clone, Debug)] +enum ProtectedRefRule { + Exact(String), + Prefix(String), +} + +impl ProtectedRefPolicy { + /// Create a policy that rejects updates to refs matching any prefix in `protected_prefixes`. + #[must_use] + pub fn new(protected_prefixes: impl IntoIterator>) -> Self { + Self { + protected_refs: protected_prefixes + .into_iter() + .map(|prefix| ProtectedRefRule::Prefix(prefix.into())) + .collect(), + } + } + + /// Create a policy that rejects exact branch names such as `main`. + #[must_use] + pub fn branches(branches: impl IntoIterator>) -> Self { + Self { + protected_refs: branches + .into_iter() + .map(|branch| { + let branch = branch.into(); + ProtectedRefRule::Exact(format!("refs/heads/{branch}")) + }) + .collect(), + } + } + + /// Create a policy that rejects exact tag names such as `v1.0.0`. + #[must_use] + pub fn tags(tags: impl IntoIterator>) -> Self { + Self { + protected_refs: tags + .into_iter() + .map(|tag| { + let tag = tag.into(); + ProtectedRefRule::Exact(format!("refs/tags/{tag}")) + }) + .collect(), + } + } +} + +#[async_trait] +impl PushPolicy for ProtectedRefPolicy { + async fn update(&self, context: &RefUpdatePolicyContext) -> Result { + if self + .protected_refs + .iter() + .any(|rule| protected_ref_matches(rule, &context.update.refname)) + { + return Ok(PolicyDecision::deny("protected ref")); + } + Ok(PolicyDecision::Allow) + } +} + +fn protected_ref_matches(rule: &ProtectedRefRule, refname: &str) -> bool { + match rule { + ProtectedRefRule::Exact(protected) => refname == protected, + ProtectedRefRule::Prefix(prefix) => refname.starts_with(prefix), + } +} + +/// Outcome recorded for a hosted write audit event. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AuditOutcome { + /// The write was accepted. + Accepted, + /// The write was rejected before all refs were applied. + Rejected { + /// Rejection reason captured from the typed error. + reason: String, + }, +} + +/// Audit record emitted for a receive-pack write attempt. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuditEvent { + /// Tenant that owns the repository. + pub tenant: TenantId, + /// Repository being pushed to. + pub repository: RepositoryId, + /// Actor that attempted the write. + pub actor: PolicyActor, + /// Ref updates requested by the write. + pub updates: Vec, + /// Commit objects introduced by the write pack. + pub pushed_commits: Vec, + /// Caller-supplied event time. + pub timestamp: OffsetDateTime, + /// Accepted or rejected outcome. + pub outcome: AuditOutcome, +} + +impl AuditEvent { + /// Create an accepted write audit event from a policy context and `timestamp`. + #[must_use] + pub fn accepted(context: &PushPolicyContext, timestamp: OffsetDateTime) -> Self { + Self { + tenant: context.tenant.clone(), + repository: context.repository.clone(), + actor: context.actor.clone(), + updates: context.updates.clone(), + pushed_commits: context.pushed_commits.clone(), + timestamp, + outcome: AuditOutcome::Accepted, + } + } + + /// Create a rejected write audit event from a policy context, `timestamp`, and `reason`. + #[must_use] + pub fn rejected( + context: &PushPolicyContext, + timestamp: OffsetDateTime, + reason: impl Into, + ) -> Self { + Self { + tenant: context.tenant.clone(), + repository: context.repository.clone(), + actor: context.actor.clone(), + updates: context.updates.clone(), + pushed_commits: context.pushed_commits.clone(), + timestamp, + outcome: AuditOutcome::Rejected { + reason: reason.into(), + }, + } + } +} + +/// Explicit audit integration for hosted write attempts. +#[async_trait] +pub trait AuditSink: Send + Sync { + /// Record `event` in the embedding platform's audit store. + /// + /// # Errors + /// + /// Returns backend or integration errors from the audit sink. + async fn record(&self, event: &AuditEvent) -> Result<()>; +} + +/// Audit sink that discards all events. +#[derive(Clone, Copy, Debug, Default)] +pub struct NoopAuditSink; + +#[async_trait] +impl AuditSink for NoopAuditSink { + async fn record(&self, _event: &AuditEvent) -> Result<()> { + Ok(()) + } +} diff --git a/grit-lib-server/src/protocol/receive_pack.rs b/grit-lib-server/src/protocol/receive_pack.rs index 4ccf58507..609d3728b 100644 --- a/grit-lib-server/src/protocol/receive_pack.rs +++ b/grit-lib-server/src/protocol/receive_pack.rs @@ -3,7 +3,6 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::io::Read; -use async_trait::async_trait; use flate2::read::ZlibDecoder; use grit_lib::check_ref_format::{check_refname_format, RefNameOptions}; use grit_lib::objects::{parse_commit, parse_tag, parse_tree, HashAlgo, ObjectId, ObjectKind}; @@ -14,9 +13,12 @@ use time::OffsetDateTime; use crate::cache::{EventPublisher, InvalidationEvent, InvalidationEventKind}; use crate::error::{Error, Result}; +use crate::policy::{AuditEvent, AuditSink, PolicyActor, PolicyRefUpdate, RefUpdatePolicyContext}; use crate::repository::ServerRepository; use crate::storage::{ReflogEntry, ServerStorage, StoredObject, StoredRef}; +pub use crate::policy::{AllowAllPushPolicy, ProtectedRefPolicy, PushPolicy, PushPolicyContext}; + /// Receive-pack capability advertised or requested on the wire. #[derive(Clone, Debug, PartialEq, Eq, Hash)] #[non_exhaustive] @@ -187,79 +189,8 @@ pub struct PushPlan { pub request: ReceivePackRequest, /// Objects decoded into the temporary quarantine. pub quarantine: Vec, -} - -/// Context passed to push policy hooks for one command. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PushPolicyContext { - /// Tenant that owns the repository. - pub tenant: crate::ids::TenantId, - /// Repository being pushed to. - pub repository: crate::ids::RepositoryId, - /// Ref being updated. - pub refname: String, - /// Old object id from the client command. - pub old_oid: ObjectId, - /// New object id from the client command. - pub new_oid: ObjectId, - /// Derived command kind. - pub kind: PushCommandKind, -} - -/// Policy hook used to reject protected or otherwise unauthorized ref updates. -#[async_trait] -pub trait PushPolicy: Send + Sync { - /// Check whether one receive-pack command may proceed. - /// - /// # Errors - /// - /// Returns [`Error::PushPolicyRejected`] or another typed error when the command is not - /// allowed. - async fn check(&self, context: &PushPolicyContext) -> Result<()>; -} - -/// Push policy that accepts every command. -#[derive(Clone, Copy, Debug, Default)] -pub struct AllowAllPushPolicy; - -#[async_trait] -impl PushPolicy for AllowAllPushPolicy { - async fn check(&self, _context: &PushPolicyContext) -> Result<()> { - Ok(()) - } -} - -/// Simple protected-ref policy based on exact prefixes. -#[derive(Clone, Debug, Default)] -pub struct ProtectedRefPolicy { - protected_prefixes: Vec, -} - -impl ProtectedRefPolicy { - /// Create a policy that rejects updates to refs matching any prefix in `protected_prefixes`. - #[must_use] - pub fn new(protected_prefixes: impl IntoIterator>) -> Self { - Self { - protected_prefixes: protected_prefixes.into_iter().map(Into::into).collect(), - } - } -} - -#[async_trait] -impl PushPolicy for ProtectedRefPolicy { - async fn check(&self, context: &PushPolicyContext) -> Result<()> { - if self - .protected_prefixes - .iter() - .any(|prefix| context.refname.starts_with(prefix)) - { - return Err(Error::PushPolicyRejected { - refname: context.refname.clone(), - reason: "protected ref".to_owned(), - }); - } - Ok(()) - } + /// Commit objects introduced by the push pack. + pub pushed_commits: Vec, } /// Per-ref status after applying a push. @@ -321,7 +252,12 @@ where /// /// Returns protocol, object closure, fast-forward, ref conflict, policy, backend, or object /// parsing errors. - pub async fn prepare_push

(&self, request: ReceivePackRequest, policy: &P) -> Result + pub async fn prepare_push

( + &self, + request: ReceivePackRequest, + actor: &PolicyActor, + policy: &P, + ) -> Result where P: PushPolicy, { @@ -330,13 +266,81 @@ where let quarantined = quarantine_map(&quarantine); self.verify_closure(&request, &quarantined).await?; self.verify_fast_forwards(&request, &quarantined).await?; - self.check_policy(&request, policy).await?; + let pushed_commits = pushed_commit_ids(&quarantine); + let context = self.policy_context(&request, actor, &pushed_commits); + self.check_policy(&context, policy).await?; Ok(PushPlan { request, quarantine, + pushed_commits, }) } + /// Prepare, apply, audit, and run all receive-pack policy hooks for one push. + /// + /// `actor` is used for authorization, hooks, audit, and reflog identity. `timestamp` is + /// supplied by the caller to keep core logic deterministic. `publisher` receives ref + /// invalidation events after durable updates, and `audit` records accepted and rejected write + /// attempts through an explicit integration. + /// + /// # Errors + /// + /// Returns validation, authorization, policy, backend, audit, or invalidation publishing + /// errors. + pub async fn receive_push( + &self, + request: ReceivePackRequest, + actor: PolicyActor, + timestamp: OffsetDateTime, + policy: &P, + publisher: &E, + audit: &A, + ) -> Result + where + P: PushPolicy, + E: EventPublisher, + A: AuditSink, + { + let request_for_audit = request.clone(); + let plan = match self.prepare_push(request, &actor, policy).await { + Ok(plan) => plan, + Err(err) => { + let pushed_commits = decode_pack(&request_for_audit.pack, self.repo.hash_algo()) + .map(|quarantine| pushed_commit_ids(&quarantine)) + .unwrap_or_default(); + let fallback_context = + self.policy_context(&request_for_audit, &actor, &pushed_commits); + audit + .record(&AuditEvent::rejected( + &fallback_context, + timestamp, + err.to_string(), + )) + .await?; + return Err(err); + } + }; + + let context = self.policy_context(&plan.request, &actor, &plan.pushed_commits); + let report = match self + .apply_push(plan, actor.reflog_identity.as_str(), timestamp, publisher) + .await + { + Ok(report) => report, + Err(err) => { + audit + .record(&AuditEvent::rejected(&context, timestamp, err.to_string())) + .await?; + return Err(err); + } + }; + policy.post_receive(&context).await?; + audit + .record(&AuditEvent::accepted(&context, timestamp)) + .await?; + Ok(report) + } + /// Apply a prepared push using guarded ref updates, reflogs, and invalidation publishing. /// /// `actor` is written to each reflog entry, `timestamp` is supplied by the caller to keep core @@ -520,21 +524,52 @@ where Ok(()) } - async fn check_policy

(&self, request: &ReceivePackRequest, policy: &P) -> Result<()> + fn policy_context( + &self, + request: &ReceivePackRequest, + actor: &PolicyActor, + pushed_commits: &[ObjectId], + ) -> PushPolicyContext { + PushPolicyContext { + tenant: self.repo.tenant().clone(), + repository: self.repo.repository().clone(), + actor: actor.clone(), + updates: request + .commands + .iter() + .map(|command| { + PolicyRefUpdate::new( + command.refname.clone(), + command.old_oid, + command.new_oid, + command.kind(), + ) + }) + .collect(), + pushed_commits: pushed_commits.to_vec(), + } + } + + async fn check_policy

(&self, context: &PushPolicyContext, policy: &P) -> Result<()> where P: PushPolicy, { - for command in &request.commands { + policy + .pre_receive(context) + .await? + .into_push_result("receive-pack")?; + for update in &context.updates { + let update_context = RefUpdatePolicyContext { + tenant: context.tenant.clone(), + repository: context.repository.clone(), + actor: context.actor.clone(), + update: update.clone(), + pushed_commits: context.pushed_commits.clone(), + }; policy - .check(&PushPolicyContext { - tenant: self.repo.tenant().clone(), - repository: self.repo.repository().clone(), - refname: command.refname.clone(), - old_oid: command.old_oid, - new_oid: command.new_oid, - kind: command.kind(), - }) - .await?; + .update(&update_context) + .await? + .into_push_result(update.refname.clone())?; } Ok(()) } @@ -792,6 +827,14 @@ fn quarantine_map(quarantine: &[QuarantinedObject]) -> HashMap Vec { + quarantine + .iter() + .filter(|quarantined| quarantined.object.kind == ObjectKind::Commit) + .map(|quarantined| quarantined.oid) + .collect() +} + fn enqueue_references(object: &StoredObject, queue: &mut VecDeque) -> Result<()> { match object.kind { ObjectKind::Commit => { diff --git a/grit-lib-server/src/repository.rs b/grit-lib-server/src/repository.rs index 91df184fe..3cb1963cc 100644 --- a/grit-lib-server/src/repository.rs +++ b/grit-lib-server/src/repository.rs @@ -8,6 +8,7 @@ use grit_lib::objects::{parse_commit, parse_tag, HashAlgo, ObjectId, ObjectKind} use crate::cache::{EventPublisher, InvalidationEvent, InvalidationEventKind}; use crate::error::{Error, Result}; use crate::ids::{RepositoryId, TenantId}; +use crate::policy::{AuditSink, PolicyActor}; use crate::protocol::receive_pack::{ PushPlan, PushPolicy, ReceivePackReport, ReceivePackRequest, ReceivePackService, }; @@ -698,12 +699,42 @@ where /// /// Returns protocol, object closure, fast-forward, ref conflict, policy, backend, or object /// parsing errors. - pub async fn prepare_push

(&self, request: ReceivePackRequest, policy: &P) -> Result + pub async fn prepare_push

( + &self, + request: ReceivePackRequest, + actor: &PolicyActor, + policy: &P, + ) -> Result + where + P: PushPolicy, + { + ReceivePackService::new(self.clone()) + .prepare_push(request, actor, policy) + .await + } + + /// Prepare, apply, audit, and run all receive-pack policy hooks for one push. + /// + /// # Errors + /// + /// Returns validation, authorization, policy, backend, audit, or invalidation publishing + /// errors. + pub async fn receive_push( + &self, + request: ReceivePackRequest, + actor: PolicyActor, + timestamp: time::OffsetDateTime, + policy: &P, + publisher: &E, + audit: &A, + ) -> Result where P: PushPolicy, + E: EventPublisher, + A: AuditSink, { ReceivePackService::new(self.clone()) - .prepare_push(request, policy) + .receive_push(request, actor, timestamp, policy, publisher, audit) .await } diff --git a/grit-lib-server/tests/receive_pack.rs b/grit-lib-server/tests/receive_pack.rs index f74b9c60d..b2ff3ac39 100644 --- a/grit-lib-server/tests/receive_pack.rs +++ b/grit-lib-server/tests/receive_pack.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use async_trait::async_trait; use flate2::write::ZlibEncoder; @@ -11,9 +11,13 @@ use grit_lib_server::cache::InvalidationEventKind; use grit_lib_server::error::{Error, Result}; use grit_lib_server::ids::{RepositoryId, TenantId}; use grit_lib_server::memory::MemoryBackend; +use grit_lib_server::policy::{ + AuditEvent, AuditOutcome, AuditSink, AuthorizationContext, AuthorizationProvider, PolicyActor, + PolicyDecision, RefUpdatePolicyContext, RepositoryPolicy, +}; use grit_lib_server::protocol::receive_pack::{ - AllowAllPushPolicy, PushPolicy, PushPolicyContext, ReceivePackCapability, ReceivePackCommand, - ReceivePackRequest, + AllowAllPushPolicy, ProtectedRefPolicy, PushPolicy, PushPolicyContext, ReceivePackCapability, + ReceivePackCommand, ReceivePackRequest, }; use grit_lib_server::repository::ServerRepository; use grit_lib_server::storage::{RefStore, ReflogStore, StoredObject, StoredRef}; @@ -167,6 +171,10 @@ fn timestamp() -> Result { .map_err(|err| Error::Backend(err.to_string())) } +fn actor() -> PolicyActor { + PolicyActor::new("tester", "tester ") +} + #[tokio::test] async fn parses_receive_pack_commands_capabilities_and_pack() -> Result<()> { let fixture = receive_fixture().await?; @@ -210,7 +218,7 @@ async fn fast_forward_push_updates_ref_reflog_and_events() -> Result<()> { let plan = fixture .repo - .prepare_push(request, &AllowAllPushPolicy) + .prepare_push(request, &actor(), &AllowAllPushPolicy) .await?; assert!(fixture.repo.read_object(&commit_oid).await?.is_none()); @@ -276,7 +284,7 @@ async fn non_fast_forward_branch_update_is_rejected() -> Result<()> { let err = fixture .repo - .prepare_push(request, &AllowAllPushPolicy) + .prepare_push(request, &actor(), &AllowAllPushPolicy) .await .err() .ok_or_else(|| Error::Backend("non-fast-forward push unexpectedly passed".to_owned()))?; @@ -306,7 +314,7 @@ async fn creates_and_deletes_branch() -> Result<()> { ); let create_plan = fixture .repo - .prepare_push(create, &AllowAllPushPolicy) + .prepare_push(create, &actor(), &AllowAllPushPolicy) .await?; fixture .repo @@ -332,7 +340,7 @@ async fn creates_and_deletes_branch() -> Result<()> { ); let delete_plan = fixture .repo - .prepare_push(delete, &AllowAllPushPolicy) + .prepare_push(delete, &actor(), &AllowAllPushPolicy) .await?; fixture .repo @@ -380,7 +388,7 @@ async fn updates_tag_without_fast_forward_check() -> Result<()> { ); let plan = fixture .repo - .prepare_push(update, &AllowAllPushPolicy) + .prepare_push(update, &actor(), &AllowAllPushPolicy) .await?; fixture .repo @@ -414,7 +422,7 @@ async fn missing_object_is_rejected_before_refs_change() -> Result<()> { let err = fixture .repo - .prepare_push(push, &AllowAllPushPolicy) + .prepare_push(push, &actor(), &AllowAllPushPolicy) .await .err() .ok_or_else(|| Error::Backend("missing object push unexpectedly passed".to_owned()))?; @@ -429,11 +437,99 @@ struct RejectAllPolicy; #[async_trait] impl PushPolicy for RejectAllPolicy { - async fn check(&self, context: &PushPolicyContext) -> Result<()> { - Err(Error::PushPolicyRejected { - refname: context.refname.clone(), - reason: "test policy".to_owned(), - }) + async fn update(&self, _context: &RefUpdatePolicyContext) -> Result { + Ok(PolicyDecision::deny("test policy")) + } +} + +#[derive(Default)] +struct RecordingAuditSink { + events: Mutex>, +} + +impl RecordingAuditSink { + fn events(&self) -> Result> { + self.events + .lock() + .map(|events| events.clone()) + .map_err(|_| Error::Backend("audit event lock poisoned".to_owned())) + } +} + +#[async_trait] +impl AuditSink for RecordingAuditSink { + async fn record(&self, event: &AuditEvent) -> Result<()> { + self.events + .lock() + .map(|mut events| events.push(event.clone())) + .map_err(|_| Error::Backend("audit event lock poisoned".to_owned())) + } +} + +struct DenyAuthorization; + +#[async_trait] +impl AuthorizationProvider for DenyAuthorization { + async fn check(&self, _context: &AuthorizationContext) -> Result { + Ok(PolicyDecision::deny("no write permission")) + } +} + +#[derive(Default)] +struct RecordingHookPolicy { + pre_receive_count: Mutex, + update_refs: Mutex>, + post_receive_count: Mutex, +} + +impl RecordingHookPolicy { + fn counts(&self) -> Result<(usize, Vec, usize)> { + let pre_receive_count = *self + .pre_receive_count + .lock() + .map_err(|_| Error::Backend("pre-receive lock poisoned".to_owned()))?; + let update_refs = self + .update_refs + .lock() + .map(|refs| refs.clone()) + .map_err(|_| Error::Backend("update hook lock poisoned".to_owned()))?; + let post_receive_count = *self + .post_receive_count + .lock() + .map_err(|_| Error::Backend("post-receive lock poisoned".to_owned()))?; + Ok((pre_receive_count, update_refs, post_receive_count)) + } +} + +#[async_trait] +impl PushPolicy for RecordingHookPolicy { + async fn pre_receive(&self, context: &PushPolicyContext) -> Result { + assert_eq!(context.actor.id, "tester"); + assert_eq!(context.updates.len(), 1); + self.pre_receive_count + .lock() + .map(|mut count| *count += 1) + .map_err(|_| Error::Backend("pre-receive lock poisoned".to_owned()))?; + Ok(PolicyDecision::Allow) + } + + async fn update(&self, context: &RefUpdatePolicyContext) -> Result { + assert_eq!(context.actor.id, "tester"); + assert_eq!(context.pushed_commits.len(), 1); + self.update_refs + .lock() + .map(|mut refs| refs.push(context.update.refname.clone())) + .map_err(|_| Error::Backend("update hook lock poisoned".to_owned()))?; + Ok(PolicyDecision::Allow) + } + + async fn post_receive(&self, context: &PushPolicyContext) -> Result<()> { + assert_eq!(context.actor.id, "tester"); + assert_eq!(context.pushed_commits.len(), 1); + self.post_receive_count + .lock() + .map(|mut count| *count += 1) + .map_err(|_| Error::Backend("post-receive lock poisoned".to_owned())) } } @@ -448,7 +544,7 @@ async fn policy_rejection_blocks_push() -> Result<()> { let err = fixture .repo - .prepare_push(push, &RejectAllPolicy) + .prepare_push(push, &actor(), &RejectAllPolicy) .await .err() .ok_or_else(|| Error::Backend("policy rejection unexpectedly passed".to_owned()))?; @@ -464,6 +560,154 @@ async fn policy_rejection_blocks_push() -> Result<()> { Ok(()) } +#[tokio::test] +async fn receive_push_runs_hooks_and_audits_accepted_write() -> Result<()> { + let fixture = receive_fixture().await?; + let audit = RecordingAuditSink::default(); + let hooks = RecordingHookPolicy::default(); + let (blob, _, tree, _, commit, commit_oid) = advanced_objects(fixture.base_commit, b"next\n"); + let push = request( + vec![command(fixture.base_commit, commit_oid, "refs/heads/main")], + pack(&[blob, tree, commit])?, + ); + + let report = fixture + .repo + .receive_push( + push, + actor(), + timestamp()?, + &hooks, + fixture.backend.as_ref(), + &audit, + ) + .await?; + assert_eq!(report.unpacked_objects, 3); + assert_eq!( + fixture.repo.resolve_ref("refs/heads/main").await?, + Some(commit_oid) + ); + + let (pre_receive_count, update_refs, post_receive_count) = hooks.counts()?; + assert_eq!(pre_receive_count, 1); + assert_eq!(update_refs, vec!["refs/heads/main"]); + assert_eq!(post_receive_count, 1); + + let events = audit.events()?; + assert_eq!(events.len(), 1); + assert!(matches!(events[0].outcome, AuditOutcome::Accepted)); + assert_eq!(events[0].actor.id, "tester"); + assert_eq!(events[0].updates[0].refname, "refs/heads/main"); + assert_eq!(events[0].pushed_commits, vec![commit_oid]); + + Ok(()) +} + +#[tokio::test] +async fn authorization_denial_blocks_push_and_audits_rejection() -> Result<()> { + let fixture = receive_fixture().await?; + let audit = RecordingAuditSink::default(); + let policy = RepositoryPolicy::new(DenyAuthorization, AllowAllPushPolicy); + let (blob, _, tree, _, commit, commit_oid) = advanced_objects(fixture.base_commit, b"next\n"); + let push = request( + vec![command(fixture.base_commit, commit_oid, "refs/heads/main")], + pack(&[blob, tree, commit])?, + ); + + let err = fixture + .repo + .receive_push( + push, + actor(), + timestamp()?, + &policy, + fixture.backend.as_ref(), + &audit, + ) + .await + .err() + .ok_or_else(|| Error::Backend("authorization denial unexpectedly passed".to_owned()))?; + assert!(matches!(err, Error::AuthorizationDenied { actor, .. } if actor == "tester")); + assert_eq!( + fixture.repo.resolve_ref("refs/heads/main").await?, + Some(fixture.base_commit) + ); + + let events = audit.events()?; + assert_eq!(events.len(), 1); + assert!(matches!(events[0].outcome, AuditOutcome::Rejected { .. })); + assert_eq!(events[0].updates[0].refname, "refs/heads/main"); + assert_eq!(events[0].pushed_commits, vec![commit_oid]); + + Ok(()) +} + +#[tokio::test] +async fn protected_branch_and_tag_policies_reject_updates() -> Result<()> { + let fixture = receive_fixture().await?; + let (blob, _, tree, _, commit, commit_oid) = advanced_objects(fixture.base_commit, b"next\n"); + let protected_branch = request( + vec![command(fixture.base_commit, commit_oid, "refs/heads/main")], + pack(&[blob, tree, commit])?, + ); + + let branch_err = fixture + .repo + .prepare_push( + protected_branch, + &actor(), + &ProtectedRefPolicy::branches(["main"]), + ) + .await + .err() + .ok_or_else(|| Error::Backend("protected branch unexpectedly passed".to_owned()))?; + assert!(matches!( + branch_err, + Error::PushPolicyRejected { refname, .. } if refname == "refs/heads/main" + )); + + let tag_payload = StoredObject::new(ObjectKind::Blob, b"tag payload\n"); + let tag_oid = tag_payload.object_id(HashAlgo::Sha1); + let protected_tag = request( + vec![command( + ObjectId::null(HashAlgo::Sha1), + tag_oid, + "refs/tags/v1", + )], + pack(&[tag_payload])?, + ); + + let tag_err = fixture + .repo + .prepare_push(protected_tag, &actor(), &ProtectedRefPolicy::tags(["v1"])) + .await + .err() + .ok_or_else(|| Error::Backend("protected tag unexpectedly passed".to_owned()))?; + assert!(matches!( + tag_err, + Error::PushPolicyRejected { refname, .. } if refname == "refs/tags/v1" + )); + + Ok(()) +} + +#[tokio::test] +async fn read_only_apis_do_not_require_authorization_provider() -> Result<()> { + let fixture = receive_fixture().await?; + + let summary = fixture.repo.summary().await?; + assert_eq!(summary.refs_count, 2); + assert_eq!( + summary + .default_branch + .ok_or_else(|| Error::Backend("missing default branch".to_owned()))? + .refname, + "refs/heads/main" + ); + + Ok(()) +} + #[tokio::test] async fn concurrent_push_conflict_is_typed() -> Result<()> { let fixture = receive_fixture().await?; @@ -480,10 +724,13 @@ async fn concurrent_push_conflict_is_typed() -> Result<()> { vec![command(fixture.base_commit, right_oid, "refs/heads/main")], pack(&[right_blob, right_tree, right_commit])?, ); - let left_plan = fixture.repo.prepare_push(left, &AllowAllPushPolicy).await?; + let left_plan = fixture + .repo + .prepare_push(left, &actor(), &AllowAllPushPolicy) + .await?; let right_plan = fixture .repo - .prepare_push(right, &AllowAllPushPolicy) + .prepare_push(right, &actor(), &AllowAllPushPolicy) .await?; fixture