Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/core/src/ci/integration_lookup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ pub async fn ensure_github_app_integrations<C: ConnectionTrait>(
secret: Set(None),
endpoint_url: Set(None),
access_token: Set(None),
allowed_ips: Set(None),
created_by: Set(creator),
created_at: Set(chrono::Utc::now().naive_utc()),
}
Expand Down Expand Up @@ -130,6 +131,7 @@ mod ensure_tests {
secret: None,
endpoint_url: None,
access_token: None,
allowed_ips: None,
created_by: user(),
created_at: NaiveDateTime::default(),
}
Expand Down
51 changes: 51 additions & 0 deletions backend/core/src/ip_allowlist.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* SPDX-FileCopyrightText: 2026 Wavelens GmbH <info@wavelens.io>
*
* SPDX-License-Identifier: AGPL-3.0-only
*/

//! Source-IP allowlist matching for API keys and inbound integrations.
//! Empty list = allow all (backwards compatible with existing rows).

use ipnet::IpNet;
use std::net::IpAddr;

pub fn is_allowed(ip: IpAddr, allowlist: &[String]) -> bool {
if allowlist.is_empty() {
return true;
}
let ip = normalize(ip);
allowlist
.iter()
.filter_map(|s| s.parse::<IpNet>().ok())
.any(|net| net.contains(&ip))
}

pub fn normalize_entry(raw: &str) -> Result<String, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err("empty entry".into());
}
if let Ok(net) = trimmed.parse::<IpNet>() {
return Ok(net.to_string());
}
if let Ok(ip) = trimmed.parse::<IpAddr>() {
let prefix = match ip {
IpAddr::V4(_) => 32,
IpAddr::V6(_) => 128,
};
let net = IpNet::new(ip, prefix).map_err(|e| e.to_string())?;
return Ok(net.to_string());
}
Err(format!("not a valid IP or CIDR: {trimmed}"))
}

fn normalize(ip: IpAddr) -> IpAddr {
match ip {
IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
Some(v4) => IpAddr::V4(v4),
None => IpAddr::V6(v6),
},
v => v,
}
}
1 change: 1 addition & 0 deletions backend/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub mod db;
pub mod executer;
pub mod http;
pub mod hydra;
pub mod ip_allowlist;
pub mod nix;
pub mod nix_hash;
pub mod permissions;
Expand Down
2 changes: 2 additions & 0 deletions backend/core/src/state/provisioning.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1398,6 +1398,7 @@ impl<'a> StateApplicator<'a> {
permission: Set(mask),
organization: Set(pinned_org),
cache: Set(None),
allowed_ips: Set(None),
};
api_key_model.insert(self.db).await?;
tracing::info!(name = %state_api_key.name, "Created managed API key");
Expand Down Expand Up @@ -1598,6 +1599,7 @@ impl<'a> StateApplicator<'a> {
secret: Set(encrypted_secret),
endpoint_url: Set(endpoint),
access_token: Set(encrypted_token),
allowed_ips: Set(None),
created_by: Set(created_by_id),
created_at: Set(now()),
};
Expand Down
7 changes: 5 additions & 2 deletions backend/core/src/storage/nar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use bytes::Bytes;
use futures::StreamExt as _;
use futures::stream::BoxStream;
pub use object_store::{MultipartUpload, WriteMultipart};
use object_store::{ObjectStore, ObjectStoreExt as _, PutPayload, path::Path};
use object_store::{ClientOptions, ObjectStore, ObjectStoreExt as _, PutPayload, path::Path};
use std::sync::Arc;

/// Unified NAR file storage abstraction over local disk or an S3-compatible backend.
Expand Down Expand Up @@ -71,7 +71,10 @@ impl NarStore {
) -> Result<Self> {
let mut builder = object_store::aws::AmazonS3Builder::new()
.with_bucket_name(bucket)
.with_region(region);
.with_region(region)
.with_client_options(ClientOptions::new().with_user_agent(
crate::http::user_agent().parse().expect("static UA is valid"),
));

if let Some(ep) = endpoint {
builder = builder
Expand Down
81 changes: 81 additions & 0 deletions backend/core/tests/ip_allowlist.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* SPDX-FileCopyrightText: 2026 Wavelens GmbH <info@wavelens.io>
*
* SPDX-License-Identifier: AGPL-3.0-only
*/

use core::ip_allowlist::{is_allowed, normalize_entry};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};

fn v4(a: u8, b: u8, c: u8, d: u8) -> IpAddr {
IpAddr::V4(Ipv4Addr::new(a, b, c, d))
}

#[test]
fn empty_list_allows_everything() {
assert!(is_allowed(v4(1, 2, 3, 4), &[]));
}

#[test]
fn slash_32_exact_match() {
let list = vec!["203.0.113.5/32".to_string()];
assert!(is_allowed(v4(203, 0, 113, 5), &list));
assert!(!is_allowed(v4(203, 0, 113, 6), &list));
}

#[test]
fn slash_24_contains_address() {
let list = vec!["10.0.0.0/24".to_string()];
assert!(is_allowed(v4(10, 0, 0, 1), &list));
assert!(is_allowed(v4(10, 0, 0, 254), &list));
assert!(!is_allowed(v4(10, 0, 1, 0), &list));
}

#[test]
fn ipv4_mapped_ipv6_matches_ipv4_cidr() {
let list = vec!["10.0.0.0/24".to_string()];
let mapped = IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0x0a00, 0x0001));
assert!(is_allowed(mapped, &list));
}

#[test]
fn malformed_entry_is_skipped_but_others_still_count() {
let list = vec!["not-an-ip".to_string(), "10.0.0.0/24".to_string()];
assert!(is_allowed(v4(10, 0, 0, 1), &list));
}

#[test]
fn no_match_when_all_entries_fail() {
let list = vec!["172.16.0.0/12".to_string()];
assert!(!is_allowed(v4(192, 168, 1, 1), &list));
}

#[test]
fn normalize_bare_ipv4_to_slash_32() {
assert_eq!(normalize_entry("203.0.113.5").unwrap(), "203.0.113.5/32");
}

#[test]
fn normalize_bare_ipv6_to_slash_128() {
assert_eq!(normalize_entry("2001:db8::1").unwrap(), "2001:db8::1/128");
}

#[test]
fn normalize_keeps_cidr_unchanged() {
assert_eq!(normalize_entry("10.0.0.0/8").unwrap(), "10.0.0.0/8");
}

#[test]
fn normalize_trims_whitespace() {
assert_eq!(normalize_entry(" 10.0.0.0/8 ").unwrap(), "10.0.0.0/8");
}

#[test]
fn normalize_rejects_garbage() {
assert!(normalize_entry("hello world").is_err());
}

#[test]
fn normalize_rejects_empty() {
assert!(normalize_entry(" ").is_err());
}
4 changes: 4 additions & 0 deletions backend/entity/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ pub struct Model {
/// other org.
pub organization: Option<OrganizationId>,
pub cache: Option<CacheId>,
/// Source CIDRs allowed to present this key. `None`/empty = any source.
#[sea_orm(column_type = "Array(std::sync::Arc::new(ColumnType::Text))", nullable)]
pub allowed_ips: Option<Vec<String>>,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
Expand Down Expand Up @@ -74,6 +77,7 @@ impl std::fmt::Debug for Model {
.field("permission", &self.permission)
.field("organization", &self.organization)
.field("cache", &self.cache)
.field("allowed_ips", &self.allowed_ips)
.finish()
}
}
Expand Down
4 changes: 4 additions & 0 deletions backend/entity/src/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ pub struct Model {
pub endpoint_url: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub access_token: Option<String>,
/// Source CIDRs allowed for inbound webhooks. `None`/empty = any source.
#[sea_orm(column_type = "Array(std::sync::Arc::new(ColumnType::Text))", nullable)]
pub allowed_ips: Option<Vec<String>>,
pub created_by: UserId,
pub created_at: NaiveDateTime,
}
Expand Down Expand Up @@ -64,6 +67,7 @@ impl std::fmt::Debug for Model {
"access_token",
&self.access_token.as_ref().map(|_| "[redacted]"),
)
.field("allowed_ips", &self.allowed_ips)
.field("created_by", &self.created_by)
.field("created_at", &self.created_at)
.finish()
Expand Down
4 changes: 4 additions & 0 deletions backend/migration/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ mod m20260525_000002_add_cache_to_api;
mod m20260525_000003_create_admin_task;
mod m20260525_000004_evaluation_check_run_ids;
mod m20260527_000000_evaluation_source_comment;
mod m20260527_000001_add_allowed_ips_to_api;
mod m20260527_000002_add_allowed_ips_to_integration;

pub struct Migrator;

Expand Down Expand Up @@ -237,6 +239,8 @@ impl MigratorTrait for Migrator {
Box::new(m20260525_000003_create_admin_task::Migration),
Box::new(m20260525_000004_evaluation_check_run_ids::Migration),
Box::new(m20260527_000000_evaluation_source_comment::Migration),
Box::new(m20260527_000001_add_allowed_ips_to_api::Migration),
Box::new(m20260527_000002_add_allowed_ips_to_integration::Migration),
]
}
}
47 changes: 47 additions & 0 deletions backend/migration/src/m20260527_000001_add_allowed_ips_to_api.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* SPDX-FileCopyrightText: 2026 Wavelens GmbH <info@wavelens.io>
*
* SPDX-License-Identifier: AGPL-3.0-only
*/

//! Nullable `allowed_ips TEXT[]` on `api`; NULL/empty = allow any source.

use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Api::Table)
.add_column(
ColumnDef::new(Api::AllowedIps)
.array(ColumnType::Text)
.null(),
)
.to_owned(),
)
.await
}

async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Api::Table)
.drop_column(Api::AllowedIps)
.to_owned(),
)
.await
}
}

#[derive(DeriveIden)]
enum Api {
Table,
AllowedIps,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* SPDX-FileCopyrightText: 2026 Wavelens GmbH <info@wavelens.io>
*
* SPDX-License-Identifier: AGPL-3.0-only
*/

//! Nullable `allowed_ips TEXT[]` on `integration`; NULL/empty = allow any source.

use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Integration::Table)
.add_column(
ColumnDef::new(Integration::AllowedIps)
.array(ColumnType::Text)
.null(),
)
.to_owned(),
)
.await
}

async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Integration::Table)
.drop_column(Integration::AllowedIps)
.to_owned(),
)
.await
}
}

#[derive(DeriveIden)]
enum Integration {
Table,
AllowedIps,
}
2 changes: 2 additions & 0 deletions backend/web/src/access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -960,6 +960,7 @@ mod tests {
organization: org,
cache_pin: None,
cache_permission_mask: None,
allowed_ips: Vec::new(),
}
}

Expand Down Expand Up @@ -1251,6 +1252,7 @@ mod tests {
organization: None,
cache_pin: None,
cache_permission_mask: Some(crate::permissions::cache_view_mask()),
allowed_ips: Vec::new(),
}
}

Expand Down
8 changes: 6 additions & 2 deletions backend/web/src/authorization/api_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use crate::permissions::PermissionMask;
use gradient_core::types::ids::CacheId;
use gradient_core::types::{ApiId, OrganizationId, UserId};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApiKeyContext {
pub api_id: ApiId,
pub mask: PermissionMask,
Expand All @@ -26,6 +26,8 @@ pub struct ApiKeyContext {
pub cache_pin: Option<CacheId>,
/// Cache-permission mask. `None` means unrestricted (i64::MAX).
pub cache_permission_mask: Option<i64>,
/// Source-IP allowlist (CIDR strings). Empty = any source allowed.
pub allowed_ips: Vec<String>,
}

/// Extension type inserted on every authenticated request.
Expand Down Expand Up @@ -89,8 +91,9 @@ mod tests {
))),
cache_pin: None,
cache_permission_mask: None,
allowed_ips: Vec::new(),
};
let wrapped = MaybeApiKey::from_key(ctx);
let wrapped = MaybeApiKey::from_key(ctx.clone());
assert_eq!(wrapped.as_ref(), Some(&ctx));
}

Expand Down Expand Up @@ -123,6 +126,7 @@ mod tests {
organization: None,
cache_pin: None,
cache_permission_mask: None,
allowed_ips: Vec::new(),
},
};
let ctx = outcome.api_key_context().expect("present");
Expand Down
Loading
Loading