diff --git a/.gitignore b/.gitignore index e4e94d3e..3f4f679f 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,6 @@ apps/gateway/target/ # DD skills (installed per-user via pup) .claude/skills/dd-* + +.claude/worktrees + diff --git a/apps/gateway/Cargo.lock b/apps/gateway/Cargo.lock index 6e62c693..ea04a10f 100644 --- a/apps/gateway/Cargo.lock +++ b/apps/gateway/Cargo.lock @@ -2355,6 +2355,7 @@ dependencies = [ "hyper 1.8.1", "hyper-util", "jsonwebtoken", + "memchr", "percent-encoding", "rcgen", "redis", diff --git a/apps/gateway/Cargo.toml b/apps/gateway/Cargo.toml index ba91eeb1..14fdcf0d 100644 --- a/apps/gateway/Cargo.toml +++ b/apps/gateway/Cargo.toml @@ -59,6 +59,10 @@ dashmap = "6" # catastrophic backtracking (no ReDoS). Compiled patterns are cached in a DashMap. regex = "1" +# Linear-time byte-substring search for body condition matching (attacker- +# controlled request bodies). Already in the tree via `regex`. +memchr = "2" + # Base64 (for gateway auth) base64 = "0.22" diff --git a/apps/gateway/src/budget.rs b/apps/gateway/src/budget.rs index a2785537..e6ec7281 100644 --- a/apps/gateway/src/budget.rs +++ b/apps/gateway/src/budget.rs @@ -1,18 +1,36 @@ -//! Budget layer — stub for the OSS build. All functions are no-ops; the cloud -//! build swaps this module for `ee/budget.rs` via `#[path]` in `main.rs`. +//! Budget layer — spend caps on org/project-owned LLM secrets (OSS). //! -//! The shared types (`BudgetBinding`, `BudgetPeriod`) and `resolve_bindings` are -//! referenced by the shared `connect.rs`/`gateway/mitm.rs` threading, so they -//! exist in both builds with the same surface — inert in OSS -//! (`resolve_bindings` always returns an empty Vec, so the threaded field stays -//! empty and the cloud-only enforcement/metering in `ee/hooks.rs` never runs). +//! An admin sets a per-secret cost cap (`Budget` row). The gateway meters LLM +//! spend against it by parsing the provider `usage` object out of the response +//! (see `gateway/hooks.rs`), pricing it against the static [`price`] table, and +//! recording it (see `telemetry.rs`). Enforcement is a PRE-request gate in +//! `hooks::pre_forward`: once a prior request pushes the running total to/over +//! the limit, the NEXT request is denied `402`. The in-flight request that +//! crosses the line completes (cost is only known at stream end), so one +//! request may overshoot — the cap blocks new requests once exceeded. +//! +//! Fail direction: the budget gate fails OPEN. A budget is a cost control, not +//! a security control — a metering/read glitch lets the request through rather +//! than causing a self-inflicted outage. Only the normal over-limit path bites. +//! +//! ⚠ KEEP THE SHARED TYPES (`BudgetBinding`, `BudgetPeriod`) IDENTICAL to +//! `ee/budget.rs`. Only one of the two modules compiles per build (feature +//! swap), so the shared threading in `connect.rs`/`gateway/mitm.rs` uses +//! whichever copy is active. Treat the types as one. + +use std::collections::HashMap; use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use tracing::warn; + +/// One cent = 1e7 nano-dollars (1e-9 USD). +pub(crate) const CENT_TO_NANOS: i64 = 10_000_000; -// ⚠ KEEP THE TYPES BELOW IDENTICAL to `ee/budget.rs`. Only one of the two -// modules compiles per build (feature swap), so a field added to one and not the -// other will NOT fail compilation — the shared threading in `connect.rs`/ -// `gateway/mitm.rs` just uses whichever copy is active. Treat them as one type. +/// TTL for the hot spend counter. A monthly period rolls to a new counter key +/// on the 1st (so a new month resets regardless of TTL); this bound just forces +/// a periodic rehydrate from the durable `BudgetSpend` floor for `total` caps. +pub(crate) const PERIOD_TTL: u64 = 60 * 60 * 24 * 40; // 40 days /// How a budget's spend window resets. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -37,14 +55,491 @@ pub(crate) struct BudgetBinding { pub period: BudgetPeriod, } -/// Resolve budget bindings for the effective partner secrets among a request's -/// host-filtered secrets. OSS: always empty (no budgets enforced). Concrete on -/// `db::SecretRow` — the cloud impl is generic over a `BudgetSecret` trait, but -/// the stub only needs to accept what `connect.rs` passes (`&[SecretRow]`). +/// Parsed token usage from a metered LLM response. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct Usage { + pub input: u64, + pub output: u64, + /// Anthropic `cache_creation_input_tokens` — cache-WRITE tokens, billed at + /// 1.25× base input and EXCLUDED from `input`. Always 0 for OpenAI (which + /// folds cached tokens into `prompt_tokens`). + pub cache_write: u64, + /// Anthropic `cache_read_input_tokens` — cache-READ tokens, billed at 0.1× + /// base input and EXCLUDED from `input`. Always 0 for OpenAI. + pub cache_read: u64, + /// Model served (from the response body) — selects the price row. + pub model: String, +} + +/// Whether this secret type has a meter + price table entry, so a budget on it +/// can actually be enforced. Everything else is DESCOPED (see the plan): a +/// budget on an unmeterable secret is rejected at create time by the API. +pub(crate) fn is_metered_type(secret_type: &str) -> bool { + matches!(secret_type, "anthropic" | "openai") +} + +/// Resolve budget bindings for the org/project LLM secrets among a request's +/// host-filtered secrets. Loads `Budget` rows for `(organization_id, secret_id ∈ +/// metered host-matched secrets)` and maps each to a [`BudgetBinding`]. Errors ⇒ +/// log + return `[]` (fail-open at resolution too — no binding ⇒ no cap). pub(crate) async fn resolve_bindings( - _pool: &sqlx::PgPool, - _org_id: &str, - _secrets: &[crate::db::SecretRow], + pool: &sqlx::PgPool, + org_id: &str, + secrets: &[crate::db::SecretRow], ) -> Vec { - Vec::new() + // Only metered LLM types can be priced; others can't carry a spend cap. + let type_by_id: HashMap<&str, &str> = secrets + .iter() + .filter(|s| is_metered_type(&s.type_)) + .map(|s| (s.id.as_str(), s.type_.as_str())) + .collect(); + if type_by_id.is_empty() { + return Vec::new(); + } + + let ids: Vec = type_by_id.keys().map(|id| id.to_string()).collect(); + let rows = match crate::db::find_budgets_for_secrets(pool, org_id, &ids).await { + Ok(rows) => rows, + Err(e) => { + warn!(error = %e, "budget: failed to load budgets; enforcing none (fail-open)"); + return Vec::new(); + } + }; + + rows.into_iter() + .filter_map(|row| { + let secret_type = type_by_id.get(row.secret_id.as_str())?; + Some(BudgetBinding { + secret_id: row.secret_id, + organization_id: org_id.to_string(), + secret_type: (*secret_type).to_string(), + limit_nanos: row.limit_cents as i64 * CENT_TO_NANOS, + period: period_from_str(&row.period), + }) + }) + .collect() +} + +fn period_from_str(s: &str) -> BudgetPeriod { + match s { + "total" => BudgetPeriod::Total, + _ => BudgetPeriod::Monthly, + } +} + +// ── Period + cache keys ────────────────────────────────────────────────── + +/// The spend-window key. Monthly → `m:YYYY-MM` (UTC), so a new month is a new +/// key = automatic reset. Total → `total` (lifetime). +pub(crate) fn period_key(period: BudgetPeriod, now: OffsetDateTime) -> String { + match period { + BudgetPeriod::Monthly => { + format!("m:{:04}-{:02}", now.year(), u8::from(now.month())) + } + BudgetPeriod::Total => "total".to_string(), + } +} + +/// The hot-counter cache key holding accumulated nano-dollars for this window. +pub(crate) fn counter_key(secret_id: &str, org_id: &str, period_key: &str) -> String { + format!("budget:spent:{secret_id}:{org_id}:{period_key}") +} + +/// Enforcement predicate: a spend cap denies the NEXT request once the running +/// total meets or exceeds the limit. `>=` — at exactly the limit, deny. +pub(crate) fn is_over(spent: i64, limit: i64) -> bool { + spent >= limit +} + +// ── Metering: parse + price ────────────────────────────────────────────── + +/// Bounded head/tail budget for the metering copy (per direction), enough for a +/// non-stream JSON `usage` (trailing) or an SSE `message_start` (leading). +pub(crate) const META_CAP: usize = 16 * 1024; + +/// Parse the provider `usage` from a bounded response sample. `head` is the +/// first bytes (SSE `message_start` with input tokens + model), `tail` the last +/// bytes (non-stream trailing `usage`, or the SSE final `message_delta` output). +/// Best-effort substring scan — tolerant of truncation; when no usage is present +/// (e.g. OpenAI SSE without `include_usage`) returns `None` ⇒ the caller charges +/// 0 (fail-open). Never fabricates. +pub(crate) fn parse_usage(secret_type: &str, head: &[u8], tail: &[u8]) -> Option { + let (in_key, out_key) = match secret_type { + "anthropic" => ("input_tokens", "output_tokens"), + "openai" => ("prompt_tokens", "completion_tokens"), + _ => return None, + }; + let h = String::from_utf8_lossy(head); + let t = String::from_utf8_lossy(tail); + + // Input tokens live in the leading usage (SSE message_start) or the trailing + // usage (non-stream) — first occurrence in either. + let input = find_uint(&h, in_key, false).or_else(|| find_uint(&t, in_key, false)); + // Output tokens live in the trailing usage / final message_delta — last + // occurrence in the tail, then the head as a degraded fallback. + let output = find_uint(&t, out_key, true).or_else(|| find_uint(&h, out_key, true)); + let model = find_str(&h, "model").or_else(|| find_str(&t, "model"))?; + + // Anthropic reports prompt-cache tokens in fields EXCLUDED from `input_tokens` + // (`cache_creation_input_tokens`, `cache_read_input_tokens`). Agent workloads + // lean heavily on caching, so omitting these systematically under-meters + // input cost. They live in the leading `message_start` usage (SSE) or the + // trailing usage (non-stream) — first occurrence in either. OpenAI folds its + // cached tokens into `prompt_tokens`, so they stay 0 there. + let (cache_write, cache_read) = if secret_type == "anthropic" { + ( + find_uint(&h, "cache_creation_input_tokens", false) + .or_else(|| find_uint(&t, "cache_creation_input_tokens", false)) + .unwrap_or(0), + find_uint(&h, "cache_read_input_tokens", false) + .or_else(|| find_uint(&t, "cache_read_input_tokens", false)) + .unwrap_or(0), + ) + } else { + (0, 0) + }; + + match (input, output, cache_write, cache_read) { + (None, None, 0, 0) => None, + (i, o, _, _) => Some(Usage { + input: i.unwrap_or(0), + output: o.unwrap_or(0), + cache_write, + cache_read, + model, + }), + } +} + +/// Price a usage into nano-dollars: `input × input_price + output × output_price`. +/// Unknown model → 0 + `warn!` once (a fabricated price on a blocking control is +/// worse than a documented under-meter). +pub(crate) fn price(secret_type: &str, usage: &Usage) -> i64 { + match price_per_token(secret_type, &usage.model) { + // Cache-write is 1.25× (×5/4) and cache-read 0.1× (÷10) of base input; + // multiply before dividing to keep the integer rounding error sub-token. + Some((per_in, per_out)) => { + usage.input as i64 * per_in + + usage.output as i64 * per_out + + (usage.cache_write as i64 * per_in * 5) / 4 + + (usage.cache_read as i64 * per_in) / 10 + } + None => { + warn!(secret_type, model = %usage.model, "budget: no price for model; metering as 0"); + 0 + } + } +} + +/// `(input_nanos_per_token, output_nanos_per_token)` for the given provider + +/// model, by longest-prefix match. Prices are nano-dollars/token = USD-per-M × +/// 1000. Static curated table; new/unknown models meter as 0 until updated. +fn price_per_token(secret_type: &str, model: &str) -> Option<(i64, i64)> { + // Ordered arbitrarily; longest matching prefix wins so specific rows + // (e.g. gpt-4o-mini) beat general ones (gpt-4o, gpt-4). + const ANTHROPIC: &[(&str, i64, i64)] = &[ + ("claude-opus-5", 5_000, 25_000), + ("claude-opus-4", 5_000, 25_000), + ("claude-opus-3", 15_000, 75_000), + ("claude-3-opus", 15_000, 75_000), + ("claude-opus", 5_000, 25_000), + ("claude-fable-5", 10_000, 50_000), + ("claude-sonnet", 3_000, 15_000), + ("claude-3-5-sonnet", 3_000, 15_000), + ("claude-3-7-sonnet", 3_000, 15_000), + ("claude-3-sonnet", 3_000, 15_000), + ("claude-haiku-4", 1_000, 5_000), + ("claude-3-5-haiku", 800, 4_000), + ("claude-3-haiku", 250, 1_250), + ("claude-haiku", 1_000, 5_000), + ]; + const OPENAI: &[(&str, i64, i64)] = &[ + ("gpt-4o-mini", 150, 600), + ("gpt-4o", 2_500, 10_000), + ("gpt-4.1-mini", 400, 1_600), + ("gpt-4.1-nano", 100, 400), + ("gpt-4.1", 2_000, 8_000), + ("gpt-4-turbo", 10_000, 30_000), + ("gpt-4", 30_000, 60_000), + ("gpt-3.5-turbo", 500, 1_500), + ("o1-mini", 1_100, 4_400), + ("o3-mini", 1_100, 4_400), + ("o1", 15_000, 60_000), + ]; + + let table = match secret_type { + "anthropic" => ANTHROPIC, + "openai" => OPENAI, + _ => return None, + }; + table + .iter() + .filter(|(prefix, _, _)| model.starts_with(prefix)) + .max_by_key(|(prefix, _, _)| prefix.len()) + .map(|(_, per_in, per_out)| (*per_in, *per_out)) +} + +/// Find the integer following `"key":` in `hay`. `last` picks the final match +/// (streamed final `message_delta`), otherwise the first (leading usage). +fn find_uint(hay: &str, key: &str, last: bool) -> Option { + let needle = format!("\"{key}\""); + let mut found = None; + let mut idx = 0; + while let Some(rel) = hay[idx..].find(&needle) { + let after = idx + rel + needle.len(); + if let Some(n) = uint_after_colon(&hay[after..]) { + found = Some(n); + if !last { + return found; + } + } + idx = after; + } + found +} + +fn uint_after_colon(s: &str) -> Option { + let s = s.trim_start().strip_prefix(':')?.trim_start(); + let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect(); + digits.parse().ok() +} + +/// Find the string value following the first `"key":` in `hay`. +fn find_str(hay: &str, key: &str) -> Option { + let needle = format!("\"{key}\""); + let rel = hay.find(&needle)?; + let rest = hay[rel + needle.len()..] + .trim_start() + .strip_prefix(':')? + .trim_start() + .strip_prefix('"')?; + let end = rest.find('"')?; + Some(rest[..end].to_string()) +} + +// ── Tests (pure unit — no DB/network) ──────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use time::{Date, Month}; + + /// Build a UTC `OffsetDateTime` without needing the `time` `macros` feature. + fn utc(year: i32, month: Month, day: u8) -> OffsetDateTime { + Date::from_calendar_date(year, month, day) + .unwrap() + .midnight() + .assume_utc() + } + + #[test] + fn is_metered_type_covers_anthropic_openai_only() { + assert!(is_metered_type("anthropic")); + assert!(is_metered_type("openai")); + assert!(!is_metered_type("generic")); + assert!(!is_metered_type("github-app")); + } + + #[test] + fn parse_anthropic_non_stream() { + let body = br#"{"id":"msg_1","model":"claude-sonnet-4-5-20250929","content":[{"type":"text","text":"hi"}],"usage":{"input_tokens":100,"output_tokens":50}}"#; + let u = parse_usage("anthropic", body, body).unwrap(); + assert_eq!(u.input, 100); + assert_eq!(u.output, 50); + assert_eq!(u.model, "claude-sonnet-4-5-20250929"); + } + + #[test] + fn parse_openai_non_stream() { + let body = br#"{"id":"cmpl","model":"gpt-4o-mini","choices":[],"usage":{"prompt_tokens":200,"completion_tokens":80,"total_tokens":280}}"#; + let u = parse_usage("openai", body, body).unwrap(); + assert_eq!(u.input, 200); + assert_eq!(u.output, 80); + assert_eq!(u.model, "gpt-4o-mini"); + } + + #[test] + fn parse_anthropic_sse_head_and_tail() { + // input + model in message_start (head); final output in message_delta (tail). + let head = br#"event: message_start +data: {"type":"message_start","message":{"model":"claude-opus-4-1-20250805","usage":{"input_tokens":1200,"output_tokens":1}}} +"#; + let tail = br#"event: message_delta +data: {"type":"message_delta","delta":{},"usage":{"output_tokens":640}} +"#; + let u = parse_usage("anthropic", head, tail).unwrap(); + assert_eq!(u.input, 1200); + assert_eq!(u.output, 640); + assert_eq!(u.model, "claude-opus-4-1-20250805"); + } + + #[test] + fn parse_anthropic_cache_tokens() { + // A cache-write turn: small non-cached input_tokens plus the more + // expensive cache_creation, and a cache_read on top — all excluded from + // input_tokens and each parsed into its own field. + let body = br#"{"id":"msg_1","model":"claude-sonnet-4-5","usage":{"input_tokens":10,"cache_creation_input_tokens":2000,"cache_read_input_tokens":500,"output_tokens":40}}"#; + let u = parse_usage("anthropic", body, body).unwrap(); + assert_eq!(u.input, 10); + assert_eq!(u.output, 40); + assert_eq!(u.cache_write, 2000); + assert_eq!(u.cache_read, 500); + } + + #[test] + fn parse_openai_ignores_cache_fields() { + // OpenAI folds cached tokens into prompt_tokens; the anthropic-only cache + // fields must never be read for openai even if present in the body. + let body = br#"{"model":"gpt-4o","usage":{"prompt_tokens":100,"completion_tokens":20,"cache_creation_input_tokens":999}}"#; + let u = parse_usage("openai", body, body).unwrap(); + assert_eq!(u.cache_write, 0); + assert_eq!(u.cache_read, 0); + } + + #[test] + fn price_anthropic_cache_tokens_exact_math() { + // sonnet base input = 3000 nanos/token. cache_write bills 1.25× (3750), + // cache_read 0.1× (300). + let u = Usage { + input: 100, + output: 50, + cache_write: 1_000, + cache_read: 2_000, + model: "claude-sonnet-4-5".to_string(), + }; + // 100*3000 + 50*15000 + 1000*3750 + 2000*300 + // = 300_000 + 750_000 + 3_750_000 + 600_000 + assert_eq!(price("anthropic", &u), 5_400_000); + } + + #[test] + fn parse_openai_sse_without_usage_is_none() { + let body = br#"data: {"choices":[{"delta":{"content":"hi"}}],"model":"gpt-4o"} + +data: [DONE] +"#; + assert!(parse_usage("openai", body, body).is_none()); + } + + #[test] + fn parse_missing_usage_but_model_is_none() { + let body = br#"{"model":"claude-sonnet-4-5","content":[]}"#; + assert!(parse_usage("anthropic", body, body).is_none()); + } + + #[test] + fn parse_truncated_garbage_is_none() { + assert!(parse_usage("anthropic", b"{not json at al", b"l").is_none()); + } + + #[test] + fn parse_wrong_provider_is_none() { + let body = br#"{"model":"x","usage":{"input_tokens":5,"output_tokens":5}}"#; + assert!(parse_usage("generic", body, body).is_none()); + } + + #[test] + fn price_known_model_exact_math() { + let u = Usage { + input: 1_000, + output: 2_000, + cache_write: 0, + cache_read: 0, + model: "claude-sonnet-4-5".to_string(), + }; + // 1000*3000 + 2000*15000 = 3_000_000 + 30_000_000 + assert_eq!(price("anthropic", &u), 33_000_000); + } + + #[test] + fn price_longest_prefix_wins() { + let mini = Usage { + input: 1_000_000, + output: 0, + cache_write: 0, + cache_read: 0, + model: "gpt-4o-mini-2024".to_string(), + }; + assert_eq!(price("openai", &mini), 150_000_000); // 1e6 * 150 + let full = Usage { + input: 1_000_000, + output: 0, + cache_write: 0, + cache_read: 0, + model: "gpt-4o-2024".to_string(), + }; + assert_eq!(price("openai", &full), 2_500_000_000); // 1e6 * 2500 + } + + #[test] + fn price_zero_tokens_is_zero() { + let u = Usage { + input: 0, + output: 0, + cache_write: 0, + cache_read: 0, + model: "claude-opus-4-1".to_string(), + }; + assert_eq!(price("anthropic", &u), 0); + } + + #[test] + fn price_unknown_model_is_zero_no_panic() { + let u = Usage { + input: 1_000, + output: 1_000, + cache_write: 0, + cache_read: 0, + model: "some-unlisted-model".to_string(), + }; + assert_eq!(price("anthropic", &u), 0); + } + + #[test] + fn period_key_monthly_zero_pads() { + let key = period_key(BudgetPeriod::Monthly, utc(2026, Month::July, 29)); + assert_eq!(key, "m:2026-07"); + let jan = period_key(BudgetPeriod::Monthly, utc(2027, Month::January, 1)); + assert_eq!(jan, "m:2027-01"); + } + + #[test] + fn period_key_dec_jan_rollover_distinct() { + let dec = period_key(BudgetPeriod::Monthly, utc(2026, Month::December, 31)); + let jan = period_key(BudgetPeriod::Monthly, utc(2027, Month::January, 1)); + assert_eq!(dec, "m:2026-12"); + assert_eq!(jan, "m:2027-01"); + assert_ne!(dec, jan); + } + + #[test] + fn period_key_total_is_constant() { + assert_eq!( + period_key(BudgetPeriod::Total, utc(2026, Month::July, 29)), + "total" + ); + } + + #[test] + fn counter_key_stable_and_collision_free() { + let a = counter_key("sec1", "org1", "m:2026-07"); + assert_eq!(a, "budget:spent:sec1:org1:m:2026-07"); + assert_ne!(a, counter_key("sec2", "org1", "m:2026-07")); + assert_ne!(a, counter_key("sec1", "org2", "m:2026-07")); + assert_ne!(a, counter_key("sec1", "org1", "m:2026-08")); + } + + #[test] + fn is_over_boundary() { + assert!(!is_over(99, 100)); // under + assert!(is_over(100, 100)); // exactly at → deny + assert!(is_over(101, 100)); // over + assert!(!is_over(0, 100)); + } + + #[test] + fn cent_to_nanos_conversion() { + // 500 cents ($5.00) → 5e9 nano-dollars + assert_eq!(500 * CENT_TO_NANOS, 5_000_000_000); + } } diff --git a/apps/gateway/src/condition_match.rs b/apps/gateway/src/condition_match.rs index 7b91098d..eea3b98d 100644 --- a/apps/gateway/src/condition_match.rs +++ b/apps/gateway/src/condition_match.rs @@ -1,14 +1,698 @@ -use crate::policy::PolicyRule; +//! OSS body/header condition matching (Tier 3a). +//! +//! A rule's `conditions` JSON (validated server-side as `RuleCondition[]`) +//! further narrows when the rule applies: every condition must hold (AND — +//! exactly like `method` + `path_pattern` already AND together). Two targets: +//! +//! - `body`: a raw byte-level match over the fully buffered request body +//! (`contains` / `equals` / `regex` via `regex::bytes` — linear-time, no +//! ReDoS, no lossy UTF-8 conversion so binary bodies can't dodge a needle). +//! - `header`: matched against the request headers. Header NAMES are +//! case-insensitive (RFC 9110, free with `HeaderMap`); header VALUES are +//! compared case-sensitively on raw bytes (`(?i)` regex serves the +//! case-insensitive cases); any value of a multi-value header satisfies the +//! condition. `exists` (header-only) needs at least one value present. +//! +//! ## Failure law (SECURITY) +//! +//! A condition that cannot be evaluated — malformed JSON, unknown +//! target/operator, missing required value/key, an uncompilable regex, or a +//! body that exceeded the buffer cap — must never weaken enforcement: +//! the rule MATCHES if it is a Block rule (over-block, fail-closed) and does +//! NOT match otherwise (an Allow-family rule falls through to the next rule / +//! the Default Rule instead of silently widening). The v2 engine routes its +//! rules through here via pseudo-rules that carry the owning rule's Block +//! action for exactly this reason (see `policy_engine/evaluate.rs`). +//! +//! A rule's `conditions` may also be a JSON OBJECT — a connection target's +//! granular session policy (`{repositories: […]}` / `{folders: […]}`), not a +//! behavioral condition. Those are vacuous here (Tier 3b's `granular_access` +//! concern), matching the server-side `isSessionPolicy` discriminator. -/// OSS stub: conditions match vacuously (no body inspection). -pub(crate) fn matches(_rule: &PolicyRule, _body: Option<&[u8]>) -> bool { - true +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + +use hyper::body::Bytes; +use hyper::header::{HeaderName, HeaderValue}; +use tracing::{debug, warn}; + +use crate::policy::{BodyCapture, MatchInput, PolicyAction, PolicyRule}; + +/// Maximum request body buffered for condition matching. A larger body is +/// forwarded intact but becomes unevaluable for body conditions (→ the +/// failure law: Block rules over-block, Allow rules fall through). No +/// prefix-only matching — an attacker could push the needle past any prefix. +pub(crate) const CONDITION_BODY_CAP: usize = 256 * 1024; + +/// One decoded behavioral condition (the server-validated `RuleCondition` +/// shape). Unknown FIELDS fail to decode (`deny_unknown_fields`) and unknown +/// target/operator VALUES decode but evaluate to `Invalid` — both route +/// through the fail-closed law, so a NEWER authoring surface (say, a future +/// `negate` flag) can never silently widen an older gateway. +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct RuleCondition { + target: String, + operator: String, + #[serde(default)] + value: Option, + #[serde(default)] + key: Option, +} + +/// Three-state condition evaluation. `Invalid` = unevaluable, routed through +/// the failure law. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CondEval { + Match, + NoMatch, + Invalid, +} + +/// The decoded shape of a rule's `conditions` JSON. +enum DecodedConditions { + /// None / session-policy object / empty array → no behavioral conditions. + Vacuous, + /// A behavioral array; each element decoded independently so one malformed + /// element poisons only itself (→ `Invalid`), not its siblings. + Behavioral(Vec>), +} + +fn decode_conditions(raw: &Option) -> DecodedConditions { + match raw { + None => DecodedConditions::Vacuous, + // An object is a connection target's granular session policy + // (`repositories`/`folders`) — scoping, not a behavioral condition. + Some(serde_json::Value::Object(_)) => DecodedConditions::Vacuous, + Some(serde_json::Value::Array(items)) if items.is_empty() => DecodedConditions::Vacuous, + Some(serde_json::Value::Array(items)) => DecodedConditions::Behavioral( + items + .iter() + .map(|item| serde_json::from_value::(item.clone()).map_err(|_| ())) + .collect(), + ), + // Any other JSON shape is malformed → one unevaluable condition. + Some(_) => DecodedConditions::Behavioral(vec![Err(())]), + } +} + +/// Whether a rule's `conditions` JSON contains at least one BODY condition — +/// the buffering predicate's core. Header-only conditions never buffer +/// (headers are always available). Elements that fail to decode do NOT count: +/// they evaluate to `Invalid` regardless of body content, so the body is +/// never needed to decide them. +pub(crate) fn has_body_condition(raw: &Option) -> bool { + match decode_conditions(raw) { + DecodedConditions::Vacuous => false, + DecodedConditions::Behavioral(conds) => conds + .iter() + .any(|c| matches!(c, Ok(cond) if cond.target == "body")), + } +} + +/// True iff any rule carries a body condition. Header conditions do not trigger +/// buffering. Kept for API symmetry and unit tests; the v2 forward path uses +/// the host-scoped `policy_engine::needs_body_buffer` instead. +#[allow(dead_code)] +pub(crate) fn needs_body_buffer(rules: &[PolicyRule]) -> bool { + rules.iter().any(|r| has_body_condition(&r.conditions_raw)) +} + +// ── Evaluation ────────────────────────────────────────────────────────── + +/// Byte-substring search (an empty needle matches anything). Linear-time +/// (`memchr::memmem`) — the haystack is an attacker-controlled request body, +/// so a naive O(haystack × needle) scan would be a cheap CPU-DoS amplifier. +fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { + memchr::memmem::find(haystack, needle).is_some() +} + +/// Compiled-program cap per pattern (1 MiB — ample for the API's 1000-char +/// patterns). The crate default is 10 MiB, which would let a rule author pin +/// gigabytes of compiled programs in the process-wide cache via nested +/// repetitions; an over-limit pattern fails to compile and routes through the +/// existing `Invalid` fail-closed path. +const REGEX_SIZE_LIMIT: usize = 1 << 20; + +fn compile_regex(pattern: &str) -> Option { + regex::bytes::RegexBuilder::new(pattern) + .size_limit(REGEX_SIZE_LIMIT) + .build() + .ok() +} + +/// Compile (or fetch) a `regex::bytes` pattern through a bounded process-wide +/// cache; `None` caches a compile failure so a broken pattern doesn't +/// recompile per request. On cache overflow, compile uncached (correctness +/// identical, just slower). +fn compiled_regex(pattern: &str) -> Option { + static CACHE: OnceLock>>> = OnceLock::new(); + const CACHE_CAP: usize = 256; + let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); + if let Ok(mut map) = cache.lock() { + if let Some(cached) = map.get(pattern) { + return cached.clone(); + } + let compiled = compile_regex(pattern); + if map.len() < CACHE_CAP { + map.insert(pattern.to_string(), compiled.clone()); + } + return compiled; + } + compile_regex(pattern) +} + +/// Apply a value operator (`contains`/`equals`/`regex`) over raw bytes. +fn eval_operator(operator: &str, haystack: &[u8], value: &str) -> CondEval { + match operator { + "contains" => { + if contains_bytes(haystack, value.as_bytes()) { + CondEval::Match + } else { + CondEval::NoMatch + } + } + "equals" => { + if haystack == value.as_bytes() { + CondEval::Match + } else { + CondEval::NoMatch + } + } + "regex" => match compiled_regex(value) { + Some(re) if re.is_match(haystack) => CondEval::Match, + Some(_) => CondEval::NoMatch, + None => CondEval::Invalid, + }, + _ => CondEval::Invalid, + } +} + +fn eval_body_condition(cond: &RuleCondition, input: &MatchInput<'_>) -> CondEval { + // `exists` is header-only ("has a body" is not a meaningful policy). + if cond.operator == "exists" { + return CondEval::Invalid; + } + // Over-cap body: unevaluable (never a prefix match — see the module doc). + if input.body_truncated { + return CondEval::Invalid; + } + let Some(value) = cond.value.as_deref() else { + return CondEval::Invalid; + }; + // Absent body is a FACT, not a failure: `needs_body_buffer` is a superset + // of "a body condition could be consulted", so `None` here genuinely means + // the request had no body (GETs, WS upgrades) → match against empty. + let body = input.body.unwrap_or(&[]); + eval_operator(&cond.operator, body, value) +} + +fn eval_header_condition(cond: &RuleCondition, input: &MatchInput<'_>) -> CondEval { + let Some(key) = cond.key.as_deref().filter(|k| !k.trim().is_empty()) else { + return CondEval::Invalid; + }; + // Header-name lookup is case-insensitive via HeaderMap; a name that isn't + // a valid header name can never have been sent → unevaluable. + let Ok(name) = HeaderName::from_bytes(key.as_bytes()) else { + return CondEval::Invalid; + }; + let values: Vec<&HeaderValue> = match input.headers { + Some(headers) => headers.get_all(&name).iter().collect(), + None => Vec::new(), + }; + if cond.operator == "exists" { + return if values.is_empty() { + CondEval::NoMatch + } else { + CondEval::Match + }; + } + let Some(value) = cond.value.as_deref() else { + return CondEval::Invalid; + }; + // Any value of a multi-value header satisfies the condition; values are + // compared case-sensitively on raw bytes (`(?i)` regex for insensitive). + let mut result = CondEval::NoMatch; + for v in values { + match eval_operator(&cond.operator, v.as_bytes(), value) { + CondEval::Match => return CondEval::Match, + CondEval::Invalid => return CondEval::Invalid, + CondEval::NoMatch => result = CondEval::NoMatch, + } + } + result +} + +fn eval_condition(cond: &RuleCondition, input: &MatchInput<'_>) -> CondEval { + match cond.target.as_str() { + // `key` on a body condition is accepted-but-ignored (reserved; a + // JSON-path narrowing could use it later without breaking anything). + "body" => eval_body_condition(cond, input), + "header" => eval_header_condition(cond, input), + _ => CondEval::Invalid, + } +} + +/// Warn ONCE per rule name that a condition is unevaluable (a stored broken +/// rule would otherwise log per request — per pseudo-rule variant on tool +/// fan-outs — and flood a busy host); repeats land at `debug!`. The seen-set +/// is bounded: past the cap, new names also log at debug (never unbounded +/// memory for log bookkeeping). +fn log_unevaluable(rule_name: &str, is_block: bool) { + use std::collections::HashSet; + static SEEN: OnceLock>> = OnceLock::new(); + const SEEN_CAP: usize = 1024; + let first = SEEN + .get_or_init(|| Mutex::new(HashSet::new())) + .lock() + .map(|mut seen| { + !seen.contains(rule_name) && seen.len() < SEEN_CAP && seen.insert(rule_name.to_string()) + }) + .unwrap_or(true); + let outcome = if is_block { + "failing closed (rule matches)" + } else { + "rule falls through" + }; + if first { + warn!(rule = %rule_name, is_block, "policy: unevaluable rule condition — {outcome}"); + } else { + debug!(rule = %rule_name, is_block, "policy: unevaluable rule condition — {outcome}"); + } +} + +/// Does the rule's condition set hold for this request? Vacuously true without +/// behavioral conditions; else ALL conditions must match (AND). Any +/// unevaluable condition applies the failure law: the rule matches iff it is +/// a Block rule (see the module doc). +pub(crate) fn matches(rule: &PolicyRule, input: &MatchInput<'_>) -> bool { + let conds = match decode_conditions(&rule.conditions_raw) { + DecodedConditions::Vacuous => return true, + DecodedConditions::Behavioral(conds) => conds, + }; + let mut all_match = true; + for cond in &conds { + let eval = match cond { + Ok(cond) => eval_condition(cond, input), + Err(()) => CondEval::Invalid, + }; + match eval { + CondEval::Match => {} + CondEval::NoMatch => all_match = false, + CondEval::Invalid => { + let is_block = matches!(rule.action, PolicyAction::Block); + log_unevaluable(&rule.name, is_block); + return is_block; + } + } + } + all_match +} + +// ── Body buffering ────────────────────────────────────────────────────── + +/// What `buffer_up_to` produced: either the complete body, or the buffered +/// prefix plus the UNREAD remainder of the stream. +enum BufferOutcome { + /// The body ended within the cap — these are ALL its bytes. + Complete(Vec), + /// The cap was exceeded: the prefix read so far (cap+ε — frame-granular) + /// and the rest of the body, still unread. + Exceeded(Vec, B), +} + +/// Accumulate DATA frames until the body ends or the cap is exceeded. +/// Trailers are dropped when the body completes within the cap — the same +/// pre-existing posture as the fully-buffered default-interception branch in +/// forward.rs (HTTP/1 chunked trailers are vanishingly rare on API traffic). +async fn buffer_up_to(mut body: B, cap: usize) -> anyhow::Result> +where + B: hyper::body::Body + Unpin, + B::Error: std::error::Error + Send + Sync + 'static, +{ + use http_body_util::BodyExt; + let mut buffered: Vec = Vec::new(); + while let Some(frame) = body.frame().await { + let frame = frame.map_err(anyhow::Error::new)?; + if let Ok(data) = frame.into_data() { + buffered.extend_from_slice(&data); + if buffered.len() > cap { + return Ok(BufferOutcome::Exceeded(buffered, body)); + } + } + } + Ok(BufferOutcome::Complete(buffered)) +} + +/// The forward stream for an over-cap body: the buffered prefix first, then +/// the remaining frames relayed one by one (never collected — the tail may be +/// arbitrarily large), so the upstream receives EXACTLY the original bytes. +fn forward_stream( + prefix: Vec, + rest: B, +) -> impl futures_util::Stream> +where + B: hyper::body::Body + Unpin, + B::Error: std::error::Error + Send + Sync + 'static, +{ + use futures_util::{StreamExt, TryStreamExt}; + let head = futures_util::stream::iter(std::iter::once(Ok(Bytes::from(prefix)))); + let tail = + http_body_util::BodyDataStream::new(rest).map_err(|e| std::io::Error::other(e.to_string())); + head.chain(tail) } +/// Buffer the request body for condition matching and rebuild the forwarding +/// body. MITM correctness: the upstream always receives the original bytes — +/// a within-cap body forwards the exact buffered bytes; an over-cap body +/// forwards the buffered prefix chained with the untouched remaining stream +/// (and captures `Truncated`, which the matcher treats as unevaluable). pub(crate) async fn prepare_body( body: hyper::body::Incoming, _method: &str, _url: &str, -) -> anyhow::Result<(Option>, reqwest::Body)> { - Ok((None, reqwest::Body::wrap(body))) +) -> anyhow::Result<(BodyCapture, reqwest::Body)> { + match buffer_up_to(body, CONDITION_BODY_CAP).await? { + BufferOutcome::Complete(bytes) => { + let fwd = reqwest::Body::from(bytes.clone()); + Ok((BodyCapture::Full(bytes), fwd)) + } + BufferOutcome::Exceeded(prefix, rest) => { + let fwd = reqwest::Body::wrap_stream(forward_stream(prefix.clone(), rest)); + Ok((BodyCapture::Truncated(prefix), fwd)) + } + } +} + +// ── Tests ─────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn rule(action: PolicyAction, conditions: &str) -> PolicyRule { + PolicyRule { + name: "Conditioned rule".to_string(), + path_pattern: "*".to_string(), + method: None, + action, + conditions_raw: Some(serde_json::from_str(conditions).expect("conditions JSON")), + } + } + + fn block(conditions: &str) -> PolicyRule { + rule(PolicyAction::Block, conditions) + } + + fn allow(conditions: &str) -> PolicyRule { + rule(PolicyAction::Allow, conditions) + } + + fn with_body(body: &[u8]) -> MatchInput<'_> { + MatchInput { + body: Some(body), + body_truncated: false, + headers: None, + } + } + + fn headers(pairs: &[(&str, &str)]) -> hyper::HeaderMap { + let mut map = hyper::HeaderMap::new(); + for (name, value) in pairs { + map.append( + HeaderName::from_bytes(name.as_bytes()).expect("header name"), + HeaderValue::from_str(value).expect("header value"), + ); + } + map + } + + fn with_headers(map: &hyper::HeaderMap) -> MatchInput<'_> { + MatchInput { + body: None, + body_truncated: false, + headers: Some(map), + } + } + + // ── Decode + vacuous shapes ───────────────────────────────────────── + + #[test] + fn no_conditions_is_vacuous() { + let mut none = block(r#"[]"#); + none.conditions_raw = None; + let empty = block(r#"[]"#); + // A session-policy OBJECT is granular scoping, not behavioral — must + // stay vacuous or every granular allow rule would stop matching. + let session = block(r#"{"repositories":["owner/repo"]}"#); + for r in [&none, &empty, &session] { + assert!(matches(r, &MatchInput::empty())); + assert!(!needs_body_buffer(std::slice::from_ref(r))); + } + } + + // ── Body operators ────────────────────────────────────────────────── + + #[test] + fn body_contains_matches_and_falls_through() { + let r = block(r#"[{"target":"body","operator":"contains","value":"needle"}]"#); + assert!(matches(&r, &with_body(b"hay needle stack"))); + assert!(!matches(&r, &with_body(b"just hay"))); + } + + #[test] + fn body_equals_and_regex_match() { + let eq = block(r#"[{"target":"body","operator":"equals","value":"exact"}]"#); + assert!(matches(&eq, &with_body(b"exact"))); + assert!(!matches(&eq, &with_body(b"exact-not"))); + + let re = allow(r#"[{"target":"body","operator":"regex","value":"(?i)delete\\s+repo"}]"#); + assert!(matches(&re, &with_body(b"please DELETE repo now"))); + assert!(!matches(&re, &with_body(b"read repo"))); + + // Raw-byte matching: a needle inside a binary body still matches. + let bin = block(r#"[{"target":"body","operator":"contains","value":"secret"}]"#); + let mut body = vec![0xFF, 0xFE, 0x00]; + body.extend_from_slice(b"secret"); + body.push(0x80); + assert!(matches(&bin, &with_body(&body))); + } + + #[test] + fn conditions_are_anded() { + let r = block( + r#"[{"target":"body","operator":"contains","value":"alpha"}, + {"target":"body","operator":"contains","value":"beta"}]"#, + ); + assert!(matches(&r, &with_body(b"alpha and beta"))); + assert!(!matches(&r, &with_body(b"alpha only"))); + } + + // ── Header conditions ─────────────────────────────────────────────── + + #[test] + fn header_name_lookup_is_case_insensitive() { + let r = block(r#"[{"target":"header","operator":"equals","key":"X-Foo","value":"bar"}]"#); + let map = headers(&[("x-foo", "bar")]); + assert!(matches(&r, &with_headers(&map))); + } + + #[test] + fn header_operators() { + let map = headers(&[("x-multi", "first"), ("x-multi", "second-value")]); + + let eq = block( + r#"[{"target":"header","operator":"equals","key":"x-multi","value":"second-value"}]"#, + ); + assert!(matches(&eq, &with_headers(&map)), "any value satisfies"); + + let contains = + block(r#"[{"target":"header","operator":"contains","key":"x-multi","value":"econd"}]"#); + assert!(matches(&contains, &with_headers(&map))); + + let re = + block(r#"[{"target":"header","operator":"regex","key":"x-multi","value":"^SECOND"}]"#); + // Values are case-SENSITIVE: an uppercase anchor misses… + assert!(!matches(&re, &with_headers(&map))); + // …and `(?i)` opts in to case-insensitive. + let re_i = block( + r#"[{"target":"header","operator":"regex","key":"x-multi","value":"(?i)^SECOND"}]"#, + ); + assert!(matches(&re_i, &with_headers(&map))); + + let exists = block(r#"[{"target":"header","operator":"exists","key":"x-multi"}]"#); + assert!(matches(&exists, &with_headers(&map))); + + // Missing header → NoMatch for every operator, exists included (an + // ALLOW falls through AND a BLOCK falls through — absence is a fact). + let missing_eq = + block(r#"[{"target":"header","operator":"equals","key":"x-gone","value":"v"}]"#); + assert!(!matches(&missing_eq, &with_headers(&map))); + let missing_exists = block(r#"[{"target":"header","operator":"exists","key":"x-gone"}]"#); + assert!(!matches(&missing_exists, &with_headers(&map))); + // No headers at all behaves like the header being absent. + assert!(!matches(&missing_exists, &MatchInput::empty())); + } + + // ── Decision I: absent body is a fact, not a failure ──────────────── + + #[test] + fn absent_body_is_empty_not_invalid() { + let r = block(r#"[{"target":"body","operator":"contains","value":"needle"}]"#); + // Even a Block rule falls through: `needs_body_buffer` guarantees a + // body-conditioned rule only ever sees `None` when there WAS no body. + assert!(!matches(&r, &MatchInput::empty())); + // But an operator satisfied by the empty body still matches. + let empty_ok = block(r#"[{"target":"body","operator":"regex","value":"^$"}]"#); + assert!(matches(&empty_ok, &MatchInput::empty())); + } + + // ── Failure law (Decisions H + J) ─────────────────────────────────── + + #[test] + fn truncated_body_fails_closed_for_block_and_open_for_allow() { + let cond = r#"[{"target":"body","operator":"contains","value":"needle"}]"#; + let truncated = MatchInput { + body: None, + body_truncated: true, + headers: None, + }; + assert!(matches(&block(cond), &truncated), "Block must over-block"); + assert!( + !matches(&allow(cond), &truncated), + "Allow must fall through" + ); + let approval = rule( + PolicyAction::ManualApproval { + rule_id: "r1".to_string(), + }, + cond, + ); + assert!(!matches(&approval, &truncated)); + let rate = rule( + PolicyAction::RateLimit { + rule_id: "r1".to_string(), + max_requests: 5, + window_secs: 60, + }, + cond, + ); + assert!(!matches(&rate, &truncated)); + } + + #[test] + fn malformed_condition_json_fails_closed_by_action() { + for cond in [ + r#"[42]"#, // garbage element + r#"[{"target":"body","operator":"telepathy","value":"x"}]"#, // unknown operator + r#"[{"target":"cookies","operator":"contains","value":"x"}]"#, // unknown target + r#"[{"target":"body","operator":"contains"}]"#, // missing value + r#"[{"target":"header","operator":"equals","value":"x"}]"#, // header w/o key + r#"[{"target":"header","operator":"equals","key":"bad name","value":"x"}]"#, + r#"[{"target":"body","operator":"exists"}]"#, // exists on body + // Unknown field: a future narrowing/inverting flag (e.g. `negate`) + // must fail decode, not silently drop and widen matching. + r#"[{"target":"body","operator":"contains","value":"x","negate":true}]"#, + r#""nonsense""#, // non-array/object + ] { + assert!(matches(&block(cond), &with_body(b"body")), "{cond}"); + assert!(!matches(&allow(cond), &with_body(b"body")), "{cond}"); + } + } + + #[test] + fn uncompilable_regex_fails_closed_for_block() { + // The headline security case: a Block whose regex Rust rejects (JS + // lookbehind) must BLOCK, never silently fall through. + let cond = r#"[{"target":"body","operator":"regex","value":"(?<=x)y["}]"#; + assert!(matches(&block(cond), &with_body(b"anything"))); + assert!(!matches(&allow(cond), &with_body(b"anything"))); + } + + #[test] + fn oversized_regex_program_fails_closed() { + // Nested repetitions can approach the compiler's size limit; capping + // it at `REGEX_SIZE_LIMIT` (instead of the 10 MiB default) keeps a + // rule author from pinning gigabytes of compiled programs in the + // process-wide cache. Over-limit patterns fail to compile → the + // Invalid fail-closed path. + assert!(compile_regex("(?:x{1000}){1000}").is_none(), "over the cap"); + assert!(compile_regex("(?i)delete\\s+repo").is_some(), "normal"); + let cond = r#"[{"target":"body","operator":"regex","value":"(?:x{1000}){1000}"}]"#; + assert!(matches(&block(cond), &with_body(b"x"))); + assert!(!matches(&allow(cond), &with_body(b"x"))); + } + + // ── Buffering predicate ───────────────────────────────────────────── + + #[test] + fn needs_body_buffer_only_for_body_conditions() { + let header_only = block(r#"[{"target":"header","operator":"exists","key":"x-api-key"}]"#); + assert!(!needs_body_buffer(&[header_only])); + let body = block(r#"[{"target":"body","operator":"contains","value":"x"}]"#); + assert!(needs_body_buffer(&[body])); + let unconditioned = PolicyRule { + name: "plain".to_string(), + path_pattern: "*".to_string(), + method: None, + action: PolicyAction::Block, + conditions_raw: None, + }; + assert!(!needs_body_buffer(&[unconditioned])); + assert!(!needs_body_buffer(&[])); + } + + // ── prepare_body / buffer_up_to (MITM correctness) ────────────────── + + #[tokio::test] + async fn buffer_with_cap_returns_exact_bytes_and_forwards_them_intact() { + use http_body_util::Full; + let payload = b"{\"content\":\"hello world\"}".to_vec(); + let body = Full::new(Bytes::from(payload.clone())); + let BufferOutcome::Complete(captured) = buffer_up_to(body, 1024).await.expect("buffer") + else { + panic!("within-cap body must buffer completely"); + }; + assert_eq!(captured, payload, "capture must be byte-identical"); + // The forwarded body is rebuilt from the same bytes (prepare_body's + // Complete arm): byte-identical to the original. + let fwd = reqwest::Body::from(captured.clone()); + assert_eq!(fwd.as_bytes(), Some(payload.as_slice())); + } + + #[tokio::test] + async fn buffer_with_cap_truncates_over_cap_and_still_forwards_everything() { + use futures_util::TryStreamExt; + use http_body_util::StreamBody; + use hyper::body::Frame; + + // Three frames, 30 bytes total, cap 10 → the capture truncates after + // the frame that crosses the cap; the upstream must still receive all + // 30 original bytes in order. + let frames: Vec, std::convert::Infallible>> = vec![ + Ok(Frame::data(Bytes::from_static(b"0123456789"))), + Ok(Frame::data(Bytes::from_static(b"abcdefghij"))), + Ok(Frame::data(Bytes::from_static(b"ABCDEFGHIJ"))), + ]; + let body = StreamBody::new(futures_util::stream::iter(frames)); + let BufferOutcome::Exceeded(prefix, rest) = buffer_up_to(body, 10).await.expect("buffer") + else { + panic!("over-cap body must report Exceeded"); + }; + assert_eq!(prefix, b"0123456789abcdefghij".to_vec(), "cap+ε prefix"); + + // Draining the reconstructed forward stream yields the FULL original + // byte sequence — nothing lost, nothing reordered. + let forwarded: Vec = forward_stream(prefix.clone(), rest) + .try_collect::>() + .await + .expect("drain forward stream") + .concat(); + assert_eq!(forwarded, b"0123456789abcdefghijABCDEFGHIJ".to_vec()); + + // And the capture is opaque to matching (only peekable). + let capture = BodyCapture::Truncated(prefix.clone()); + assert_eq!(capture.bytes(), Some(prefix.as_slice())); + assert_eq!(capture.bytes_for_matching(), None); + } } diff --git a/apps/gateway/src/connect.rs b/apps/gateway/src/connect.rs index 085c0ec3..212a7f2d 100644 --- a/apps/gateway/src/connect.rs +++ b/apps/gateway/src/connect.rs @@ -745,8 +745,11 @@ impl PolicyEngine { } resolved_session_policy = session_policy; resolved_connection_id = connection_id; - } - if resolved_provider.is_none() { + // Attribute the provider dispatched to `apply_resource_scope` + // to the SAME serving connection as its session_policy and + // connection_id — mirroring the single-connection paths. + // Setting it on the first-with-rules would decouple the + // provider from the scope actually enforced. resolved_provider = Some(provider); } match (earliest_expires_at, token_expires_at) { diff --git a/apps/gateway/src/db.rs b/apps/gateway/src/db.rs index c99c9af8..beff8deb 100644 --- a/apps/gateway/src/db.rs +++ b/apps/gateway/src/db.rs @@ -55,6 +55,14 @@ pub(crate) struct SecretRow { pub metadata: Option, } +/// A budget row from the `budgets` table (cost cap on a secret for an org). +#[derive(Debug, FromRow)] +pub(crate) struct BudgetRow { + pub secret_id: String, + pub limit_cents: i32, + pub period: String, +} + /// A user row from the `users` table. #[derive(Debug, FromRow)] pub(crate) struct UserRow { @@ -344,6 +352,73 @@ pub(crate) async fn find_secrets_by_org( .context("querying secrets by organization_id") } +/// Load budgets for the given org and secret ids (host-matched metered LLM +/// secrets). Returns one row per bound secret (0/1 in practice per host). +pub(crate) async fn find_budgets_for_secrets( + pool: &PgPool, + organization_id: &str, + secret_ids: &[String], +) -> Result> { + sqlx::query_as::<_, BudgetRow>( + r#"SELECT secret_id, limit_cents, period + FROM budgets + WHERE organization_id = $1 AND secret_id = ANY($2)"#, + ) + .bind(organization_id) + .bind(secret_ids) + .fetch_all(pool) + .await + .context("querying budgets for secrets") +} + +/// Read the durable accumulated spend (nano-dollars) for a `(secret, org, +/// period)` window. `None` when the window has no recorded spend yet. +pub(crate) async fn read_budget_spend( + pool: &PgPool, + secret_id: &str, + organization_id: &str, + period: &str, +) -> Result> { + let row: Option<(i64,)> = sqlx::query_as( + r#"SELECT spent_nanos FROM budget_spends + WHERE secret_id = $1 AND organization_id = $2 AND period = $3"#, + ) + .bind(secret_id) + .bind(organization_id) + .bind(period) + .fetch_optional(pool) + .await + .context("reading budget spend")?; + Ok(row.map(|r| r.0)) +} + +/// Accumulate `delta_nanos` into the durable spend floor for a `(secret, org, +/// period)` window and return the new total. The durable floor is rehydrated +/// into the hot counter on cache miss so a flush can't silently refill a budget. +pub(crate) async fn upsert_budget_spend( + pool: &PgPool, + secret_id: &str, + organization_id: &str, + period: &str, + delta_nanos: i64, +) -> Result { + let row: (i64,) = sqlx::query_as( + r#"INSERT INTO budget_spends (secret_id, organization_id, period, spent_nanos, updated_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (secret_id, organization_id, period) + DO UPDATE SET spent_nanos = budget_spends.spent_nanos + $4, updated_at = NOW() + RETURNING spent_nanos"#, + ) + .bind(secret_id) + .bind(organization_id) + .bind(period) + .bind(delta_nanos) + .fetch_one(pool) + .await + .context("upserting budget spend")?; + Ok(row.0) +} + /// Update a secret's encrypted value (used for token refresh). pub(crate) async fn update_secret_value( pool: &PgPool, diff --git a/apps/gateway/src/gateway.rs b/apps/gateway/src/gateway.rs index d4d83fd3..56ac91b7 100644 --- a/apps/gateway/src/gateway.rs +++ b/apps/gateway/src/gateway.rs @@ -985,6 +985,8 @@ async fn handle_http_proxy( let mut resolved_body_transform: Option = None; // Granular-access policy of the connection that wins injection (if any). let mut resolved_session_policy: Option = None; + // Provider of that connection — dispatches the resource-scope gate. + let mut resolved_provider: Option = None; // Id of the connection that wins injection — same attribution law as // `resolved_session_policy`; unlike `connection_label` below, it MUST be // threaded (policy decisions bind to it). @@ -1014,6 +1016,7 @@ async fn handle_http_proxy( finalizer, body_transform, session_policy, + provider, connection_id: winning_connection_id, .. }) => { @@ -1021,6 +1024,7 @@ async fn handle_http_proxy( resolved_finalizer = finalizer; resolved_body_transform = body_transform; resolved_session_policy = session_policy; + resolved_provider = Some(provider); resolved_connection_id = winning_connection_id; } Ok(AppConnectionResult::Ambiguous { connections }) => { @@ -1104,6 +1108,7 @@ async fn handle_http_proxy( finalizer: resolved_finalizer, body_transform: resolved_body_transform, claim_token: resolved.claim_token, + provider: resolved_provider, session_policy: resolved_session_policy, winning_connection_id: resolved_connection_id, budget_bindings: resolved.budget_bindings, diff --git a/apps/gateway/src/gateway/forward.rs b/apps/gateway/src/gateway/forward.rs index 9e6631ad..4b1fb66c 100644 --- a/apps/gateway/src/gateway/forward.rs +++ b/apps/gateway/src/gateway/forward.rs @@ -21,7 +21,7 @@ use crate::apps; use crate::cache::CacheStore; use crate::default_interceptions; use crate::inject; -use crate::policy::{self, PolicyDecision}; +use crate::policy::{self, BodyCapture, MatchInput, PolicyDecision}; use crate::policy_engine; use super::hooks; @@ -162,36 +162,43 @@ pub(crate) async fn forward_request( default_interceptions::match_target(super::strip_port(host), &path, &method) .filter(|_| content_length_at_most(req.headers(), MAX_DEFAULT_INTERCEPT_BODY)); - // Buffer the request body for condition matching, when the request guard needs - // to inspect it (e.g. Dropbox folder scoping reads the JSON body), or for a - // matched default interception. In OSS, both predicates return false → zero - // overhead unless a default interception matched. - let (condition_buffer, req) = if crate::policy_engine::needs_body_buffer(&rules.policy_rules_v2) - || hooks::needs_request_body(rules, host, method.as_str(), &path) - { - let (parts, incoming) = req.into_parts(); - let (buf, fwd_body) = - crate::condition_match::prepare_body(incoming, method.as_str(), &url).await?; - (buf, hyper::Request::from_parts(parts, fwd_body)) - } else if default_target.is_some() { - // OSS-safe: fully buffer the known-small body, keeping the bytes for both - // the interception check and (if it declines) forwarding. - let (parts, incoming) = req.into_parts(); - let bytes = incoming - .collect() - .await - .context("buffering request body for default interception")? - .to_bytes(); - let req = hyper::Request::from_parts(parts, reqwest::Body::from(bytes.clone())); - (Some(bytes.to_vec()), req) - } else { - (None, req.map(reqwest::Body::wrap)) - }; + // Buffer the request body for condition matching, when a body-conditioned + // rule could govern this host (`needs_body_buffer` is host-scoped), when the + // request guard needs to inspect it (e.g. Dropbox folder scoping reads the + // JSON body), or for a matched default interception. Unconditioned traffic + // keeps streaming → zero overhead. + let (capture, req) = + if crate::policy_engine::needs_body_buffer(&rules.policy_rules_v2, policy_host) + || crate::policy_engine::needs_scope_body( + rules.provider.as_deref().unwrap_or(""), + host, + rules.session_policy.as_ref(), + ) + || hooks::needs_request_body(rules, host, method.as_str(), &path) + { + let (parts, incoming) = req.into_parts(); + let (capture, fwd_body) = + crate::condition_match::prepare_body(incoming, method.as_str(), &url).await?; + (capture, hyper::Request::from_parts(parts, fwd_body)) + } else if default_target.is_some() { + // OSS-safe: fully buffer the known-small body, keeping the bytes for both + // the interception check and (if it declines) forwarding. + let (parts, incoming) = req.into_parts(); + let bytes = incoming + .collect() + .await + .context("buffering request body for default interception")? + .to_bytes(); + let req = hyper::Request::from_parts(parts, reqwest::Body::from(bytes.clone())); + (BodyCapture::Full(bytes.to_vec()), req) + } else { + (BodyCapture::None, req.map(reqwest::Body::wrap)) + }; // Answer a matched default interception before any forwarding. A handler that // declines (e.g. a real refresh token) falls through to normal forwarding. if let Some(target) = default_target { - if let Some(synth) = target.handle(condition_buffer.as_deref().unwrap_or(&[])) { + if let Some(synth) = target.handle(capture.bytes().unwrap_or(&[])) { info!(method = %method, url = %url, "default interception — serving synthetic response"); return Ok(response::json(synth.status, synth.body)); } @@ -218,6 +225,12 @@ pub(crate) async fn forward_request( )); } + // The per-request condition input: the captured body (only a FULL capture is + // matchable — a truncated one fails closed) plus the pre-injection request + // headers. Borrows `req`; its last use is the `evaluate` call below, which NLL + // releases before `req.into_parts()` later. + let match_input = MatchInput::from_capture(&capture, req.headers()); + // The first-match engine over the published `policy_rules_v2` is authoritative. // `policy_host` is the pre-rewrite rule-match host; `is_llm_host(host)` is the // effective host for the deny-default carve. @@ -226,7 +239,7 @@ pub(crate) async fn forward_request( policy_host, method.as_str(), &path, - condition_buffer.as_deref(), + &match_input, has_injections, policy::is_llm_host(host), rules.winning_connection_id.as_deref(), @@ -235,6 +248,30 @@ pub(crate) async fn forward_request( ) .await; + // ── Granular resource-scope gate (Tier 3b) ──────────────────────────────── + // Tighten the engine's decision by the winning connection's granular scope + // (GitHub repositories / Dropbox folders). Run unconditionally on the final + // decision from EITHER engine — session policy is a property of the + // connection, not the rule generation, so it must enforce on legacy / + // pre-cutover projects too. A monotone tightening: an allow-family verdict + // for an out-of-scope or indeterminate resource becomes `Blocked`; an + // existing block is untouched. A scope block is authored by no rule, so it + // drops the matched-rule attribution. + let (decision, matched_rule) = { + let (decision, scope_blocked) = policy_engine::apply_resource_scope( + decision, + rules.provider.as_deref().unwrap_or(""), + host, + rules.session_policy.as_ref(), + &path, + &match_input, + ); + if scope_blocked { + warn!(method = %method, url = %url, "BLOCKED by resource scope"); + } + (decision, if scope_blocked { None } else { matched_rule }) + }; + // ── Early return for block / rate-limit / default-deny (no body needed) ─── match &decision { PolicyDecision::BlockedByDefaultPolicy => { @@ -346,7 +383,7 @@ pub(crate) async fn forward_request( method.as_str(), &path, &headers, - condition_buffer.as_deref(), + capture.bytes(), ) .await { @@ -378,8 +415,8 @@ pub(crate) async fn forward_request( // Peek a bounded prefix of the body for the summary + preview, then // build the forwarding body. If condition buffering already captured // the body, reuse that buffer instead of peeking the stream again. - let (summary_bytes, fwd_body): (Cow<'_, [u8]>, reqwest::Body) = if let Some(ref buf) = - condition_buffer + let (summary_bytes, fwd_body): (Cow<'_, [u8]>, reqwest::Body) = if let Some(buf) = + capture.bytes() { // Body already buffered for condition matching — borrow its prefix // for the summary instead of copying it again. diff --git a/apps/gateway/src/gateway/hooks.rs b/apps/gateway/src/gateway/hooks.rs index 7724703e..a5b50b63 100644 --- a/apps/gateway/src/gateway/hooks.rs +++ b/apps/gateway/src/gateway/hooks.rs @@ -1,23 +1,36 @@ //! Forward hooks — extension points for the request forwarding pipeline. //! -//! OSS version: all hooks are no-ops. The cloud build swaps this module -//! via `#[path = "ee/hooks.rs"]` to add cloud-specific telemetry. +//! OSS spend budgets live here: +//! - [`pre_forward`] is the PRE-request enforcement gate: read the running spend +//! total for each metered budget binding and deny `402` if it already meets or +//! exceeds the cap. Fails OPEN — a read error lets the request through. +//! - [`track_and_wrap`] is the POST-response meter: when a metered budget binding +//! applies it tees a bounded copy of the response stream, parses the provider +//! `usage` at stream end, prices it, and attaches a `BudgetCharge` to the +//! emitted `RequestEvent` (the telemetry flush accumulates it). use std::pin::Pin; +use std::task::{Context, Poll}; -use futures_util::TryStreamExt; +use futures_util::{Stream, TryStreamExt}; use http_body_util::{Either, Full, StreamBody}; use hyper::body::{Bytes, Frame}; -use hyper::Response; +use hyper::header::CONTENT_TYPE; +use hyper::{Response, StatusCode}; use super::mitm::ResolvedRules; use super::ProxyContext; +use crate::budget::{self, BudgetBinding}; // ── Shared types ──────────────────────────────────────────────────────── pub(crate) type BodyStream = Pin, reqwest::Error>> + Send>>; +/// Raw upstream body stream (bytes), before framing. +type RawBodyStream = + Pin> + Send>>; + pub(crate) type ForwardResponseBody = Either, StreamBody>; /// Common telemetry fields for a proxied request, passed from forward to hooks. @@ -45,12 +58,33 @@ pub(crate) struct RequestMeta { // ── Hooks ─────────────────────────────────────────────────────────────── +/// Pre-forward request-header hook. When a metered budget binding applies to this +/// host, force `Accept-Encoding: identity`. +/// +/// The meter reads the provider `usage` object out of the response bytes +/// ([`track_and_wrap`]). `reqwest` is built WITHOUT gzip/brotli/deflate, and the +/// client's `accept-encoding` is otherwise forwarded upstream verbatim — so a +/// client default of `gzip` (the httpx/urllib3 SDKs) would make the provider +/// return compressed bytes that [`budget::parse_usage`] cannot scan, silently +/// charging 0 and never firing the cap. Overriding to `identity` keeps the tee'd +/// copy parseable. (Streaming/SSE is served identity-encoded anyway; this closes +/// the non-stream compressed gap.) pub(crate) fn prepare_request( - _rules: &ResolvedRules, + rules: &ResolvedRules, _host: &str, _path: &str, - _headers: &mut hyper::HeaderMap, + headers: &mut hyper::HeaderMap, ) { + let has_metered = rules + .budget_bindings + .iter() + .any(|b| budget::is_metered_type(&b.secret_type)); + if has_metered { + headers.insert( + hyper::header::ACCEPT_ENCODING, + hyper::header::HeaderValue::from_static("identity"), + ); + } } /// Whether the request guard needs the buffered request body to make a @@ -64,22 +98,104 @@ pub(crate) fn needs_request_body( false } +/// PRE-request budget gate. For each metered budget binding, read the running +/// spend total and deny `402 Payment Required` if it already meets/exceeds the +/// cap (stricter-verdict: any binding over ⇒ deny). Runs after the security +/// gates, so a policy-denied request is never budget-checked. Fails OPEN — if a +/// total can't be read, the request proceeds. #[allow(clippy::too_many_arguments)] pub(crate) async fn pre_forward( - _rules: &ResolvedRules, + rules: &ResolvedRules, _proxy_ctx: &ProxyContext, _host: &str, - _cache: &dyn crate::cache::CacheStore, - _pool: &sqlx::PgPool, + cache: &dyn crate::cache::CacheStore, + pool: &sqlx::PgPool, _injection_count: usize, _method: &str, _path: &str, _headers: &hyper::HeaderMap, _body: Option<&[u8]>, ) -> Option> { + for binding in &rules.budget_bindings { + if !budget::is_metered_type(&binding.secret_type) { + continue; + } + let period_key = budget::period_key(binding.period, time::OffsetDateTime::now_utc()); + // Fail-open: only a readable total that is over the cap denies. + if let Some(spent) = read_running_total(cache, pool, binding, &period_key).await { + if budget::is_over(spent, binding.limit_nanos) { + tracing::warn!( + secret_id = %binding.secret_id, + spent, + limit = binding.limit_nanos, + "BUDGET exceeded — denying request (402)" + ); + return Some(budget_denied_response(binding, spent)); + } + } + } None } +/// Read the hot spend counter (nano-dollars). On cache miss, rehydrate once from +/// the durable `BudgetSpend` floor and seed the counter. Returns `None` only on +/// an unreadable state (fail-open at the call site). +async fn read_running_total( + cache: &dyn crate::cache::CacheStore, + pool: &sqlx::PgPool, + binding: &BudgetBinding, + period_key: &str, +) -> Option { + let key = budget::counter_key(&binding.secret_id, &binding.organization_id, period_key); + if let Some(raw) = cache.get_raw(&key).await { + return raw.parse::().ok(); + } + match crate::db::read_budget_spend( + pool, + &binding.secret_id, + &binding.organization_id, + period_key, + ) + .await + { + Ok(spent) => { + let value = spent.unwrap_or(0); + cache + .set_raw(&key, &value.to_string(), budget::PERIOD_TTL) + .await; + Some(value) + } + Err(e) => { + tracing::warn!(error = %e, "budget: spend read failed; allowing request (fail-open)"); + None + } + } +} + +/// Build the structured `402` deny body so the SDK can render "spend cap reached" +/// distinctly from a `429` rate-limit. +fn budget_denied_response(binding: &BudgetBinding, spent: i64) -> Response { + let period = match binding.period { + budget::BudgetPeriod::Monthly => "monthly", + budget::BudgetPeriod::Total => "total", + }; + let body = serde_json::json!({ + "error": "budget_exceeded", + "secretId": binding.secret_id, + "period": period, + "limitCents": binding.limit_nanos / budget::CENT_TO_NANOS, + "spentCents": spent / budget::CENT_TO_NANOS, + }); + let bytes = serde_json::to_vec(&body).unwrap_or_default(); + let mut resp = Response::new(Either::Left(Full::new(Bytes::from(bytes)))); + *resp.status_mut() = StatusCode::PAYMENT_REQUIRED; + resp.headers_mut().insert( + CONTENT_TYPE, + hyper::header::HeaderValue::from_static("application/json"), + ); + resp +} + /// Request-body transform hook. OSS: passthrough. The cloud build injects a /// claim note into LLM requests for unclaimed (partner-created) orgs. pub(crate) async fn prepare_request_body( @@ -90,13 +206,36 @@ pub(crate) async fn prepare_request_body( body } +/// POST-response telemetry + optional spend metering. When a metered budget +/// binding applies, the response stream is teed (bounded copy) so the emitted +/// `RequestEvent` carries a priced `BudgetCharge`; otherwise telemetry emits +/// immediately and the stream passes through unchanged. pub(crate) fn track_and_wrap( meta: RequestMeta, - _rules: &ResolvedRules, + rules: &ResolvedRules, _resp_headers: &hyper::HeaderMap, stream: impl futures_util::Stream> + Send + 'static, ) -> BodyStream { - crate::telemetry::on_request(crate::telemetry::RequestEvent { + let event = build_event(meta); + + // 0/1 metered binding per host in practice; take the first that can be priced. + let metered = rules + .budget_bindings + .iter() + .find(|b| budget::is_metered_type(&b.secret_type)) + .cloned(); + + match metered { + Some(binding) => Box::pin(MeteredStream::new(Box::pin(stream), event, binding)), + None => { + crate::telemetry::on_request(event); + Box::pin(stream.map_ok(Frame::data)) + } + } +} + +fn build_event(meta: RequestMeta) -> crate::telemetry::RequestEvent { + crate::telemetry::RequestEvent { org_id: meta.org_id, project_id: meta.project_id, agent_id: meta.agent_id, @@ -118,6 +257,103 @@ pub(crate) fn track_and_wrap( log_id: None, budget_charge: None, matched_rule: meta.matched_rule, - }); - Box::pin(stream.map_ok(Frame::data)) + } +} + +// ── Metered response stream ────────────────────────────────────────────── + +/// Wraps the upstream body: forwards every frame unchanged while appending a +/// bounded head (leading bytes — SSE `message_start`) and rolling tail (trailing +/// bytes — non-stream `usage` / SSE final `message_delta`) copy. At stream end +/// it parses + prices the usage and emits the `RequestEvent` with the charge. If +/// the stream is dropped before completion, the base event (no charge) is still +/// emitted — fail-open on the charge, no lost request log. +struct MeteredStream { + inner: RawBodyStream, + head: Vec, + tail: Vec, + /// Taken exactly once — by `finalize` on clean end, else by `Drop`. + event: Option, + binding: BudgetBinding, +} + +impl MeteredStream { + fn new( + inner: RawBodyStream, + event: crate::telemetry::RequestEvent, + binding: BudgetBinding, + ) -> Self { + Self { + inner, + head: Vec::new(), + tail: Vec::new(), + event: Some(event), + binding, + } + } + + fn absorb(&mut self, bytes: &[u8]) { + if self.head.len() < budget::META_CAP { + let take = (budget::META_CAP - self.head.len()).min(bytes.len()); + self.head.extend_from_slice(&bytes[..take]); + } + self.tail.extend_from_slice(bytes); + if self.tail.len() > budget::META_CAP { + let excess = self.tail.len() - budget::META_CAP; + self.tail.drain(0..excess); + } + } + + /// Compute the charge and emit the event on clean stream end. + fn finalize(&mut self) { + let Some(mut event) = self.event.take() else { + return; + }; + if let Some(usage) = budget::parse_usage(&self.binding.secret_type, &self.head, &self.tail) + { + let cost = budget::price(&self.binding.secret_type, &usage); + if cost > 0 { + event.budget_charge = Some(crate::telemetry_core::BudgetCharge { + secret_id: self.binding.secret_id.clone(), + organization_id: self.binding.organization_id.clone(), + period_key: budget::period_key( + self.binding.period, + time::OffsetDateTime::now_utc(), + ), + cost_nanos: cost, + }); + } + } + crate::telemetry::on_request(event); + } +} + +impl Stream for MeteredStream { + type Item = Result, reqwest::Error>; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + match this.inner.as_mut().poll_next(cx) { + Poll::Ready(Some(Ok(bytes))) => { + this.absorb(&bytes); + Poll::Ready(Some(Ok(Frame::data(bytes)))) + } + Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))), + Poll::Ready(None) => { + this.finalize(); + Poll::Ready(None) + } + Poll::Pending => Poll::Pending, + } + } +} + +impl Drop for MeteredStream { + fn drop(&mut self) { + // Dropped before clean end (client disconnect): emit the base event so + // the request log is preserved; no charge (fail-open). + if let Some(event) = self.event.take() { + crate::telemetry::on_request(event); + } + } } diff --git a/apps/gateway/src/gateway/mitm.rs b/apps/gateway/src/gateway/mitm.rs index 47507143..2b6074eb 100644 --- a/apps/gateway/src/gateway/mitm.rs +++ b/apps/gateway/src/gateway/mitm.rs @@ -234,10 +234,15 @@ pub(crate) struct ResolvedRules { /// Cloud-only: pending claim token when the org is in claim mode. Inert in OSS. #[cfg_attr(not(edition_cloud), allow(dead_code))] pub claim_token: Option, + /// Provider of the app connection that won injection (e.g. "github-app", + /// "dropbox"), or `None` when no app connection served the request (secret / + /// vault injection). Threaded so the request-time resource-scope gate can + /// dispatch `session_policy` to the right per-provider extractor. + pub provider: Option, /// Per-agent resource policy (e.g. Dropbox folder allowlist) for the - /// connection serving this host. Consumed by the cloud request guard to - /// enforce granular access; `None` in the common, unrestricted case. - #[cfg_attr(not(edition_cloud), allow(dead_code))] + /// connection serving this host. Enforced by the granular resource-scope + /// gate (`policy_engine::apply_resource_scope`); `None` in the common, + /// unrestricted case. pub session_policy: Option, /// Id of the app connection that won injection for this request; `None` /// when no connection serves it (secret/vault/uncredentialed traffic, the @@ -324,6 +329,8 @@ async fn resolve_rules( let mut body_transform: Option = None; // Granular-access policy of the connection that wins injection (if any). let mut session_policy: Option = None; + // Provider of that connection — dispatches the resource-scope gate. + let mut provider: Option = None; // Id of the connection that wins injection (if any) — rides with // `session_policy` under the same attribution law. let mut winning_connection_id: Option = None; @@ -358,6 +365,7 @@ async fn resolve_rules( finalizer: f, body_transform: bt, session_policy: sp, + provider: prov, connection_id: cid, .. }) => { @@ -368,6 +376,7 @@ async fn resolve_rules( finalizer = f; body_transform = bt; session_policy = sp; + provider = Some(prov); winning_connection_id = cid; } Ok(AppConnectionResult::Ambiguous { connections }) => { @@ -458,6 +467,7 @@ async fn resolve_rules( finalizer, body_transform, claim_token: resp.claim_token, + provider, session_policy, winning_connection_id, budget_bindings: resp.budget_bindings, diff --git a/apps/gateway/src/gateway/websocket.rs b/apps/gateway/src/gateway/websocket.rs index 23d53cd9..e0397e1c 100644 --- a/apps/gateway/src/gateway/websocket.rs +++ b/apps/gateway/src/gateway/websocket.rs @@ -21,7 +21,7 @@ use tracing::{info, warn}; use crate::cache::CacheStore; use crate::inject; -use crate::policy::{self, PolicyDecision}; +use crate::policy::{self, MatchInput, PolicyDecision}; use super::hooks; use super::mitm::ResolvedRules; @@ -127,6 +127,15 @@ pub(super) async fn handle_websocket( )); } + // A WebSocket upgrade is a GET with no inspectable body: header conditions + // apply to the handshake, and the absent body is a FACT (a body condition + // matches against the empty body, never fails closed on missing bytes). + let match_input = MatchInput { + body: None, + body_truncated: false, + headers: Some(req.headers()), + }; + // The first-match engine over `policy_rules_v2` is authoritative. WebSocket // blocks emit no telemetry today, so the matched rule is not attributed here // (allow-attribution for ws is out of scope) — only the decision is consumed. @@ -135,7 +144,7 @@ pub(super) async fn handle_websocket( policy_host, "GET", &path, - None, + &match_input, has_injections, policy::is_llm_host(host), rules.winning_connection_id.as_deref(), @@ -144,6 +153,23 @@ pub(super) async fn handle_websocket( ) .await; + // Granular resource-scope gate (Tier 3b), applied for symmetry with + // `forward.rs` / defense-in-depth. No covered provider serves + // resource-addressed operations over WebSocket (GitHub is URL-only and has + // no repo-addressed WS surface; Dropbox has none), and a WS upgrade is a + // GET with no buffered body — so a Dropbox RPC scope would fail closed here + // rather than pass. WS blocks emit no telemetry, so the scope-block flag is + // not consumed. + let decision = crate::policy_engine::apply_resource_scope( + decision, + rules.provider.as_deref().unwrap_or(""), + host, + rules.session_policy.as_ref(), + &path, + &match_input, + ) + .0; + match &decision { PolicyDecision::BlockedByDefaultPolicy => { warn!(host = %host, path = %path, "WebSocket BLOCKED by default deny policy"); diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs index d66b9f19..ecb9d9b7 100644 --- a/apps/gateway/src/main.rs +++ b/apps/gateway/src/main.rs @@ -43,10 +43,11 @@ mod org_routes; mod connect; -// Body-condition matcher (step 9.5): the real matcher rides with the full EE -// engine — onprem included, else a v2 `body contains` block rule would never -// see a body there and fail OPEN. The OSS arm stays the no-op (conditions are -// carried but never evaluated in OSS, matching its legacy behavior). +// Body-condition matcher (Tier 3a): the OSS arm evaluates body/header +// conditions byte-level over the buffered request body and headers, at both +// org and project scopes, with the fail-closed-by-action failure law (an +// unevaluable condition over-blocks a Block rule and drops any other). The EE +// build swaps in the cloud overlay via the `#[path]` module below. #[cfg(edition_oss)] mod condition_match; diff --git a/apps/gateway/src/policy.rs b/apps/gateway/src/policy.rs index 5fbe1489..689c1a6c 100644 --- a/apps/gateway/src/policy.rs +++ b/apps/gateway/src/policy.rs @@ -38,6 +38,84 @@ pub(crate) struct PolicyRule { pub conditions_raw: Option, } +/// Everything a rule condition can look at for one request. Built once per +/// request (forward.rs / websocket.rs) and borrowed through the whole +/// evaluation — the shared matcher and the v2 two-level walk. +/// +/// Lives here (not in `condition_match`) because `condition_match` is +/// edition-swapped and the shared call sites need one stable shape. +#[derive(Debug, Clone, Copy)] +pub(crate) struct MatchInput<'a> { + /// The fully buffered request body, when condition buffering captured it. + /// `None` means the request genuinely has no (buffered) body — GETs, + /// WebSocket upgrades, or the streaming path (`needs_body_buffer` is a + /// superset of "some body condition could be consulted", so a + /// body-conditioned rule never sees `None` for a request that had a body). + pub body: Option<&'a [u8]>, + /// The body exceeded the buffer cap: body conditions become unevaluable + /// and fail closed per the condition failure law (`condition_match`). + pub body_truncated: bool, + /// The request headers at evaluation time (pre-injection). + pub headers: Option<&'a hyper::HeaderMap>, +} + +impl<'a> MatchInput<'a> { + /// No body, no headers — for call sites (and tests) with nothing to match + /// conditions against. + #[allow(dead_code)] // production paths build real inputs; tests + EE use this + pub(crate) const fn empty() -> Self { + MatchInput { + body: None, + body_truncated: false, + headers: None, + } + } + + /// The per-request input: what `prepare_body` captured plus the request + /// headers. + pub(crate) fn from_capture(capture: &'a BodyCapture, headers: &'a hyper::HeaderMap) -> Self { + MatchInput { + body: capture.bytes_for_matching(), + body_truncated: matches!(capture, BodyCapture::Truncated(_)), + headers: Some(headers), + } + } +} + +/// What `condition_match::prepare_body` captured of a request body. +#[derive(Debug)] +pub(crate) enum BodyCapture { + /// Nothing buffered (the streaming path). + None, + /// The complete body (within the cap). + Full(Vec), + /// The first cap(+ε) bytes; the rest streams to the upstream untouched. + /// Body conditions never match on a truncated capture — a prefix-only + /// match would let a needle pushed past the cap dodge a Block rule. + Truncated(Vec), +} + +impl BodyCapture { + /// The captured bytes, full or truncated — for consumers that only peek + /// (default interception, approval summaries). + pub(crate) fn bytes(&self) -> Option<&[u8]> { + match self { + BodyCapture::None => None, + BodyCapture::Full(b) | BodyCapture::Truncated(b) => Some(b), + } + } + + /// The bytes condition matching may consult: only a FULL capture. A + /// truncated capture exposes nothing here — matching a prefix is exactly + /// the bypass the failure law refuses. + pub(crate) fn bytes_for_matching(&self) -> Option<&[u8]> { + match self { + BodyCapture::Full(b) => Some(b), + _ => None, + } + } +} + /// The v2 rule that decided a request — recorded into telemetry so Activity /// can say "decided by rule X". `logical_id` is the generation-stable identity /// (row ids regenerate on every publish); the name is a display snapshot; @@ -139,14 +217,14 @@ pub(crate) fn matches_request( rule: &PolicyRule, method: &str, path: &str, - body: Option<&[u8]>, + input: &MatchInput<'_>, ) -> bool { let direct = path_matches(path, &rule.path_pattern) && rule .method .as_ref() .is_none_or(|m| m.eq_ignore_ascii_case(method)) - && crate::condition_match::matches(rule, body); + && crate::condition_match::matches(rule, input); if direct { return true; } @@ -157,7 +235,7 @@ pub(crate) fn matches_request( && method.eq_ignore_ascii_case("GET") && is_git_push_discovery(path) { - return crate::condition_match::matches(rule, body); + return crate::condition_match::matches(rule, input); } false } @@ -191,12 +269,12 @@ pub(crate) fn is_llm_host(host: &str) -> bool { pub(crate) fn is_blocked( request_method: &str, request_path: &str, - request_body: Option<&[u8]>, + input: &MatchInput<'_>, rules: &[PolicyRule], ) -> bool { rules.iter().any(|rule| { matches!(rule.action, PolicyAction::Block) - && matches_request(rule, request_method, request_path, request_body) + && matches_request(rule, request_method, request_path, input) }) } @@ -224,7 +302,7 @@ mod tests { assert!(is_blocked( "POST", "/gmail/v1/users/me/messages/send", - None, + &MatchInput::empty(), &rules )); } @@ -235,7 +313,7 @@ mod tests { assert!(!is_blocked( "GET", "/gmail/v1/users/me/messages/send", - None, + &MatchInput::empty(), &rules )); } @@ -246,7 +324,7 @@ mod tests { assert!(!is_blocked( "POST", "/gmail/v1/users/me/messages", - None, + &MatchInput::empty(), &rules )); } @@ -254,9 +332,24 @@ mod tests { #[test] fn blocks_all_methods_when_none() { let rules = vec![block_rule("/admin/*", None)]; - assert!(is_blocked("GET", "/admin/users", None, &rules)); - assert!(is_blocked("POST", "/admin/users", None, &rules)); - assert!(is_blocked("DELETE", "/admin/settings", None, &rules)); + assert!(is_blocked( + "GET", + "/admin/users", + &MatchInput::empty(), + &rules + )); + assert!(is_blocked( + "POST", + "/admin/users", + &MatchInput::empty(), + &rules + )); + assert!(is_blocked( + "DELETE", + "/admin/settings", + &MatchInput::empty(), + &rules + )); } #[test] @@ -265,36 +358,56 @@ mod tests { assert!(is_blocked( "POST", "/gmail/v1/users/me/messages/send", - None, + &MatchInput::empty(), + &rules + )); + assert!(!is_blocked( + "POST", + "/calendar/v1/events", + &MatchInput::empty(), &rules )); - assert!(!is_blocked("POST", "/calendar/v1/events", None, &rules)); } #[test] fn blocks_all_paths() { let rules = vec![block_rule("*", Some("DELETE"))]; - assert!(is_blocked("DELETE", "/anything", None, &rules)); - assert!(!is_blocked("GET", "/anything", None, &rules)); + assert!(is_blocked( + "DELETE", + "/anything", + &MatchInput::empty(), + &rules + )); + assert!(!is_blocked( + "GET", + "/anything", + &MatchInput::empty(), + &rules + )); } #[test] fn method_matching_is_case_insensitive() { let rules = vec![block_rule("*", Some("POST"))]; - assert!(is_blocked("post", "/path", None, &rules)); - assert!(is_blocked("Post", "/path", None, &rules)); + assert!(is_blocked("post", "/path", &MatchInput::empty(), &rules)); + assert!(is_blocked("Post", "/path", &MatchInput::empty(), &rules)); } #[test] fn no_rules_allows_everything() { - assert!(!is_blocked("POST", "/anything", None, &[])); + assert!(!is_blocked("POST", "/anything", &MatchInput::empty(), &[])); } #[test] fn blocks_with_default_wildcard_path() { let rules = vec![block_rule("*", Some("POST"))]; - assert!(is_blocked("POST", "/any/path/here", None, &rules)); - assert!(is_blocked("POST", "/", None, &rules)); + assert!(is_blocked( + "POST", + "/any/path/here", + &MatchInput::empty(), + &rules + )); + assert!(is_blocked("POST", "/", &MatchInput::empty(), &rules)); } #[test] @@ -303,8 +416,18 @@ mod tests { block_rule("/safe/*", Some("GET")), block_rule("/danger/*", Some("POST")), ]; - assert!(!is_blocked("POST", "/safe/path", None, &rules)); - assert!(is_blocked("POST", "/danger/path", None, &rules)); + assert!(!is_blocked( + "POST", + "/safe/path", + &MatchInput::empty(), + &rules + )); + assert!(is_blocked( + "POST", + "/danger/path", + &MatchInput::empty(), + &rules + )); } // ── Git push discovery tests ──────────────────────────────────── @@ -315,7 +438,7 @@ mod tests { assert!(is_blocked( "GET", "/owner/repo.git/info/refs?service=git-receive-pack", - None, + &MatchInput::empty(), &rules )); } @@ -326,7 +449,7 @@ mod tests { assert!(!is_blocked( "GET", "/owner/repo.git/info/refs?service=git-upload-pack", - None, + &MatchInput::empty(), &rules )); } @@ -337,7 +460,7 @@ mod tests { assert!(is_blocked( "POST", "/owner/repo.git/git-receive-pack", - None, + &MatchInput::empty(), &rules )); } diff --git a/apps/gateway/src/policy_engine.rs b/apps/gateway/src/policy_engine.rs index c23d92da..cf290977 100644 --- a/apps/gateway/src/policy_engine.rs +++ b/apps/gateway/src/policy_engine.rs @@ -4,19 +4,24 @@ //! builds, so the shared call sites in `connect.rs`, `gateway/forward.rs`, and //! `gateway/websocket.rs` never change. //! -//! The OSS scope (the §2.9 locked matrix — exactly today's capabilities, -//! restructured): project rules only, agent/any identities, all four target -//! kinds, allow/block with the approval + rate-limit modifiers, the project -//! Default Rule terminal under the `enforce_deny` carve, and the explicit-agent -//! injection selection its equipment migration requires. Org scope, directory -//! identities, granular session policies, availability, and the shadow -//! comparator are OneCLI Cloud capabilities and have no code here. +//! The OSS scope: org + project rules composed two-level (each level reduced +//! first-match, combined under the hard-floor law mirroring +//! `policy-translation/evaluator.ts`), agent/user/group/any identities (the +//! directory kinds matched against the connection's resolved `PrincipalSet`), +//! all four target kinds, allow/block with the approval + rate-limit modifiers, +//! each level's Default Rule terminal under the `enforce_deny` carve, and the +//! explicit-agent injection selection its equipment migration requires. There +//! is no agent-group concept (deleted). Granular session-policy conditions, +//! app availability, and the shadow comparator remain OneCLI Cloud +//! capabilities and have no code here. mod assemble; mod catalog; mod enforce; mod evaluate; mod inject_select; +mod loaders; +mod scope; mod types; // The corpus parity test lives in the PRIVATE tree (`src/ee/policy_engine/`) @@ -30,3 +35,8 @@ mod oss_parity_test; pub(crate) use enforce::{evaluate, load_available_apps, load_connect_v2, needs_body_buffer}; pub(crate) use inject_select::derive_inject_selection; +// Tier 3b granular resource-scope enforcement: the request-time tightening gate +// and its buffering predicate, applied in `gateway::forward` on the final +// decision from either engine (independent of the v2 cutover — session policy +// is a property of the connection, not the rule generation). +pub(crate) use scope::{apply_resource_scope, needs_body as needs_scope_body}; diff --git a/apps/gateway/src/policy_engine/assemble.rs b/apps/gateway/src/policy_engine/assemble.rs index bc5d19aa..b8ee3e9d 100644 --- a/apps/gateway/src/policy_engine/assemble.rs +++ b/apps/gateway/src/policy_engine/assemble.rs @@ -1,29 +1,39 @@ -//! Decode the loaded published project rows into the evaluator's `Rule` list. -//! The rows are already new-model; this maps shapes and resolves -//! connection/secret targets through the fenced connect-time maps. +//! Decode the loaded published rows of ONE scope (org or project) into the +//! evaluator's `Rule` list. The rows are already new-model; this maps shapes +//! and resolves connection/secret targets through the fenced connect-time maps. use crate::db::{ ConnectionProviders, PolicyIdentityRow, PolicyRuleV2Row, PolicyTargetRow, SecretHosts, }; -use super::types::{Action, Identity, RateWindow, Rule, Target}; +use super::types::{Action, Identity, RateWindow, Rule, RuleScope, Target}; -/// Agent identities match by id; every other principal kind is a OneCLI Cloud -/// capability and decodes to `Other`, which never matches — a stored directory -/// identity narrows its rule to nothing rather than widening it (fail-closed). +/// Decode each identity row to its principal kind (the DB `one_principal` +/// CHECK guarantees at most one column is set). `agent_id`/`user_id`/`group_id` +/// decode to the matching directory kind; a row naming NO principal the OSS +/// engine understands decodes to `Other`, which never matches — it narrows its +/// rule to nothing rather than widening it (fail-closed). There is no +/// agent-group column, so no agent-group case exists. fn decode_identities(rows: &[PolicyIdentityRow]) -> Vec { rows.iter() - .map(|r| match &r.agent_id { - Some(id) => Identity::Agent(id.clone()), - None => Identity::Other, + .map(|r| { + if let Some(id) = &r.agent_id { + Identity::Agent(id.clone()) + } else if let Some(id) = &r.user_id { + Identity::User(id.clone()) + } else if let Some(id) = &r.group_id { + Identity::Group(id.clone()) + } else { + Identity::Other + } }) .collect() } /// Resolve a `secret` target to the host pattern(s) it gates: a specific /// `secret_id` via the fenced by-id map (absent/deleted → none → never -/// matches), or a `secret_scope` level union. The maps are project-fenced at -/// load, so a forged/foreign id resolves to nothing. +/// matches), or a `secret_scope` level union. The maps are org+project-fenced +/// at load, so a forged/foreign id resolves to nothing. fn secret_target_hosts(r: &PolicyTargetRow, secret_hosts: &SecretHosts) -> Vec { if let Some(id) = &r.secret_id { secret_hosts.by_id.get(id).cloned().unwrap_or_default() @@ -93,11 +103,13 @@ fn rate_window(name: Option<&str>) -> Option { fn decode_row( row: &PolicyRuleV2Row, + scope: RuleScope, secret_hosts: &SecretHosts, connection_providers: &ConnectionProviders, ) -> Rule { Rule { id: row.id.clone(), + scope, logical_id: row.logical_id.clone(), name: row.name.clone(), priority: usize::try_from(row.priority).unwrap_or(0), @@ -121,20 +133,23 @@ fn decode_row( } } -/// Assemble the loaded project rows for the evaluator. `source="equipment"` -/// rows are injection-only — their connection/secret target names a credential -/// to inject at connect, not a policy grant — and are DROPPED here. That drop -/// is load-bearing: a `secret` target PERMITS its host, so an undropped -/// equipment rule would silently grant network access alongside its injection. +/// Assemble one scope's loaded rows for the evaluator, tagging each with the +/// scope it came from. `source="equipment"` rows are injection-only — their +/// connection/secret target names a credential to inject at connect, not a +/// policy grant — and are DROPPED here. That drop is load-bearing: a `secret` +/// target PERMITS its host, so an undropped equipment rule would silently +/// grant network access alongside its injection. Org secret/connection targets +/// resolve through the SAME fenced maps as project ones (`find_secret_hosts` / +/// `find_connection_providers` already fetch org+project). pub(super) fn assemble( - project_rows: &[PolicyRuleV2Row], + rows: &[PolicyRuleV2Row], + scope: RuleScope, secret_hosts: &SecretHosts, connection_providers: &ConnectionProviders, ) -> Vec { - project_rows - .iter() + rows.iter() .filter(|row| row.source != "equipment") - .map(|row| decode_row(row, secret_hosts, connection_providers)) + .map(|row| decode_row(row, scope, secret_hosts, connection_providers)) .collect() } @@ -176,6 +191,7 @@ mod tests { ]; let rules = assemble( &rows, + RuleScope::Project, &SecretHosts::default(), &ConnectionProviders::default(), ); @@ -183,20 +199,78 @@ mod tests { assert_eq!(rules[0].id, "keep"); } + /// Test #12: agent-group is provably absent — every directory identity kind + /// the DB carries (agent/user/group) decodes to a live variant, a group id + /// decodes to `Group` (never a swallowed agent-group), and a principal-less + /// row is `Other`. There is no agent-group column or variant to decode. #[test] - fn directory_identities_decode_to_other_never_agent() { + fn agent_user_and_group_identities_decode_and_a_no_principal_row_is_other() { let rows = vec![row(|r| { - r.identities = Json(vec![serde_json::from_value( - json!({"agentId": null, "userId": null, "groupId": "g1"}), - ) - .expect("identity row")]); + r.identities = Json( + serde_json::from_value(json!([ + {"agentId": "a1", "userId": null, "groupId": null}, + {"agentId": null, "userId": "u1", "groupId": null}, + {"agentId": null, "userId": null, "groupId": "g1"}, + {"agentId": null, "userId": null, "groupId": null}, + ])) + .expect("identity rows"), + ); })]; let rules = assemble( &rows, + RuleScope::Project, + &SecretHosts::default(), + &ConnectionProviders::default(), + ); + assert!(matches!(&rules[0].identities[0], Identity::Agent(id) if id == "a1")); + assert!(matches!(&rules[0].identities[1], Identity::User(id) if id == "u1")); + assert!(matches!(&rules[0].identities[2], Identity::Group(id) if id == "g1")); + assert!(matches!(rules[0].identities[3], Identity::Other)); + } + + #[test] + fn rules_are_tagged_with_the_scope_they_were_assembled_for() { + let rows = vec![row(|_| {})]; + let org = assemble( + &rows, + RuleScope::Organization, + &SecretHosts::default(), + &ConnectionProviders::default(), + ); + let project = assemble( + &rows, + RuleScope::Project, &SecretHosts::default(), &ConnectionProviders::default(), ); - assert!(matches!(rules[0].identities[0], Identity::Other)); + assert_eq!(org[0].scope, RuleScope::Organization); + assert_eq!(project[0].scope, RuleScope::Project); + } + + #[test] + fn org_scope_targets_resolve_through_the_same_fenced_maps() { + let mut hosts = SecretHosts::default(); + hosts + .by_id + .insert("s1".to_string(), vec!["api.example.com".to_string()]); + let mut providers = ConnectionProviders::default(); + providers + .by_id + .insert("c1".to_string(), "github".to_string()); + let rows = vec![row(|r| { + r.targets = Json(vec![ + target(json!({"kind": "secret", "secretId": "s1"})), + target(json!({"kind": "connection", "appConnectionId": "c1", "appTools": []})), + ]); + })]; + let rules = assemble(&rows, RuleScope::Organization, &hosts, &providers); + assert!( + matches!(&rules[0].targets[0], Target::Secret { host_patterns } if host_patterns == &["api.example.com".to_string()]) + ); + assert!(matches!( + &rules[0].targets[1], + Target::Connection { id, provider, .. } if id == "c1" && provider == "github" + )); } #[test] @@ -211,7 +285,12 @@ mod tests { target(json!({"kind": "connection", "appConnectionId": "missing", "appTools": []})), ]); })]; - let rules = assemble(&rows, &SecretHosts::default(), &providers); + let rules = assemble( + &rows, + RuleScope::Project, + &SecretHosts::default(), + &providers, + ); assert!(matches!( &rules[0].targets[0], Target::Connection { id, provider, .. } if id == "c1" && provider == "github" @@ -233,7 +312,12 @@ mod tests { target(json!({"kind": "secret", "secretId": "deleted"})), ]); })]; - let rules = assemble(&rows, &hosts, &ConnectionProviders::default()); + let rules = assemble( + &rows, + RuleScope::Project, + &hosts, + &ConnectionProviders::default(), + ); assert!( matches!(&rules[0].targets[0], Target::Secret { host_patterns } if host_patterns == &["api.example.com".to_string()]) ); @@ -259,7 +343,12 @@ mod tests { let rows = vec![row(|r| { r.targets = Json(vec![target(json!({"kind": "secret", "secretId": "s1"}))]); })]; - let rules = assemble(&rows, &hosts, &ConnectionProviders::default()); + let rules = assemble( + &rows, + RuleScope::Project, + &hosts, + &ConnectionProviders::default(), + ); let Target::Secret { host_patterns } = &rules[0].targets[0] else { panic!("expected a secret target"); }; @@ -292,6 +381,7 @@ mod tests { ]; let rules = assemble( &rows, + RuleScope::Project, &SecretHosts::default(), &ConnectionProviders::default(), ); diff --git a/apps/gateway/src/policy_engine/catalog.rs b/apps/gateway/src/policy_engine/catalog.rs index 40685243..0f23755a 100644 --- a/apps/gateway/src/policy_engine/catalog.rs +++ b/apps/gateway/src/policy_engine/catalog.rs @@ -16,7 +16,7 @@ use std::sync::OnceLock; use serde::Deserialize; use crate::connect::host_matches; -use crate::policy::{matches_request, PolicyAction, PolicyRule}; +use crate::policy::{matches_request, MatchInput, PolicyAction, PolicyRule}; /// One tool's endpoint fan-out (camelCase JSON keys). An empty `methods` list /// means "any method". @@ -52,19 +52,28 @@ fn single_host_family(provider_tools: &HashMap) -> bool { } /// A throwaway `policy::PolicyRule` so one path×method variant routes through -/// the gateway's exact `matches_request` (the action is irrelevant to -/// matching). Conditions ride from the owning rule — vacuous in OSS, where the -/// `condition_match` arm is the no-op. +/// the gateway's exact `matches_request`. Conditions ride from the owning +/// rule, and so does its BLOCK-ness: `condition_match`'s failure law is +/// action-aware (an unevaluable condition fails CLOSED only for a Block +/// rule), so hardcoding Allow here would fail a v2 Block open. The owning +/// rule's NAME rides along too, so the matcher's unevaluable-condition +/// warning identifies the broken rule. fn variant_rule( + name: &str, path_pattern: &str, method: Option, conditions: &Option, + is_block: bool, ) -> PolicyRule { PolicyRule { - name: String::new(), + name: name.to_string(), path_pattern: path_pattern.to_string(), method, - action: PolicyAction::Allow, + action: if is_block { + PolicyAction::Block + } else { + PolicyAction::Allow + }, conditions_raw: conditions.clone(), } } @@ -91,23 +100,40 @@ fn variant_rule( /// bleed across sibling services. A truly distinct endpoint host (github /// `raw.githubusercontent.com`, fly.io GraphQL) is a separate catalog tool of /// its own; whole-app rules also cover it. +#[allow(clippy::too_many_arguments)] pub(super) fn app_target_matches( + rule_name: &str, provider: &str, tools: &[String], request_host: &str, request_method: &str, request_path: &str, - body: Option<&[u8]>, + input: &MatchInput<'_>, conditions: &Option, + is_block: bool, ) -> bool { let Some(provider_tools) = catalog().get(provider) else { return false; }; if tools.is_empty() { - return provider_tools + // Behavioral conditions gate the whole-app match too (a wildcard-path + // variant carrying the owning rule's Block-ness, so an unevaluable + // condition fails closed by action) — otherwise a conditioned + // whole-app ALLOW would match on host alone and could shadow a later + // Block. A connection target's session-policy OBJECT stays vacuous in + // `condition_match::decode_conditions`, so granular connection rules + // are unaffected. + let host_hit = provider_tools .values() .any(|tool| host_matches(request_host, &tool.host_pattern)) || crate::apps::provider_matches_host_and_path(provider, request_host, request_path); + return host_hit + && matches_request( + &variant_rule(rule_name, "*", None, conditions, is_block), + request_method, + request_path, + input, + ); } // The host is the app's per-tool catalog host OR an injection MIRROR of the // app (tool-independent → computed once): a path-scoped mirror (Gmail's @@ -139,8 +165,14 @@ pub(super) fn app_target_matches( }; tool.paths.iter().any(|path| { methods.iter().any(|method| { - let rule = variant_rule(path, method.map(str::to_string), conditions); - matches_request(&rule, request_method, request_path, body) + let rule = variant_rule( + rule_name, + path, + method.map(str::to_string), + conditions, + is_block, + ); + matches_request(&rule, request_method, request_path, input) }) }) }) @@ -152,7 +184,98 @@ mod tests { fn matches(provider: &str, tools: &[&str], host: &str, method: &str, path: &str) -> bool { let tools: Vec = tools.iter().map(|s| s.to_string()).collect(); - app_target_matches(provider, &tools, host, method, path, None, &None) + app_target_matches( + "test rule", + provider, + &tools, + host, + method, + path, + &MatchInput::empty(), + &None, + false, + ) + } + + #[test] + fn conditions_gate_the_tool_fanout_and_fail_closed_for_block() { + // A tool-scoped target honors the owning rule's conditions through the + // variant fan-out, and the variant carries the rule's Block-ness so an + // unevaluable condition fails closed exactly like a network target. + let tools = vec!["create_issue".to_string()]; + let hit = |input: &MatchInput<'_>, conditions: &str, is_block: bool| { + app_target_matches( + "test rule", + "github", + &tools, + "api.github.com", + "POST", + "/repos/o/r/issues", + input, + &serde_json::from_str(conditions).ok(), + is_block, + ) + }; + let cond = r#"[{"target":"body","operator":"contains","value":"needle"}]"#; + let with_needle = MatchInput { + body: Some(b"has needle"), + body_truncated: false, + headers: None, + }; + let without_needle = MatchInput { + body: Some(b"nothing"), + body_truncated: false, + headers: None, + }; + assert!(hit(&with_needle, cond, false)); + assert!(!hit(&without_needle, cond, false)); + // Unevaluable (uncompilable regex): matches only when Block-owned. + let broken = r#"[{"target":"body","operator":"regex","value":"("}]"#; + assert!(hit(&without_needle, broken, true)); + assert!(!hit(&without_needle, broken, false)); + } + + #[test] + fn conditions_gate_the_whole_app_match_and_fail_closed_for_block() { + // The empty-tools branch mirrors the tool fan-out: behavioral + // conditions gate the host-wide match, and an unevaluable condition + // fails closed only when the owning rule is a Block. Without this a + // conditioned whole-app ALLOW would match on host alone and shadow a + // later Block. + let hit = |input: &MatchInput<'_>, conditions: &str, is_block: bool| { + app_target_matches( + "test rule", + "github", + &[], + "api.github.com", + "DELETE", + "/anything", + input, + &serde_json::from_str(conditions).ok(), + is_block, + ) + }; + let cond = r#"[{"target":"body","operator":"contains","value":"needle"}]"#; + let with_needle = MatchInput { + body: Some(b"has needle"), + body_truncated: false, + headers: None, + }; + let without_needle = MatchInput { + body: Some(b"nothing"), + body_truncated: false, + headers: None, + }; + assert!(hit(&with_needle, cond, false)); + assert!(!hit(&without_needle, cond, false)); + // Unevaluable (uncompilable regex): matches only when Block-owned. + let broken = r#"[{"target":"body","operator":"regex","value":"("}]"#; + assert!(hit(&without_needle, broken, true)); + assert!(!hit(&without_needle, broken, false)); + // A session-policy OBJECT (granular connection scope) stays vacuous — + // the whole-app match is unaffected by it. + let session = r#"{"repositories":["o/r"]}"#; + assert!(hit(&without_needle, session, false)); } #[test] @@ -344,7 +467,17 @@ mod tests { continue; } assert!( - app_target_matches(provider, &[], &host, "POST", &path, None, &None), + app_target_matches( + "test rule", + provider, + &[], + &host, + "POST", + &path, + &MatchInput::empty(), + &None, + false + ), "whole-app rule for `{provider}` must cover its injection host `{host}` (path `{path}`)" ); } diff --git a/apps/gateway/src/policy_engine/enforce.rs b/apps/gateway/src/policy_engine/enforce.rs index 1e8d0f13..6e37107c 100644 --- a/apps/gateway/src/policy_engine/enforce.rs +++ b/apps/gateway/src/policy_engine/enforce.rs @@ -1,8 +1,13 @@ -//! The OSS enforce seam: load the published project rules at connection -//! resolution and decide requests with the first-match core, producing the -//! `policy::PolicyDecision` the forward/websocket act-path understands. The engine -//! is authoritative — an empty rule set (a load error, or an unmigrated project -//! with no Default Rule) decides `Allow`; there is no fallback. +//! The OSS enforce seam: load the published org + project rules (and, when a +//! rule targets a directory identity, the connection's principal set) at +//! connection resolution and decide requests with the two-level first-match +//! core, producing the `policy::PolicyDecision` the forward/websocket act-path +//! understands. The engine is authoritative — there is no legacy fallback. +//! +//! Fail-closed: every resolution query PROPAGATES its error (anyhow) so the +//! caller (`connect.rs`, via `.map_err(db_err)?`) REFUSES the CONNECT rather +//! than caching a policy-free (allow-everything, inject-nothing) state for the +//! ~60s cache cycle. The agent simply retries. //! //! HIGH PERFORMANCE: rules load ONCE at connection resolution (cached ~60s //! with the rest of the connect state); the per-request decision path never @@ -14,52 +19,96 @@ use sqlx::PgPool; use crate::cache::CacheStore; use crate::db::{ find_connection_providers, find_published_policy_rules_v2_by_project, find_secret_hosts, - AvailableApps, ConnectionProviders, PolicyRuleV2Row, PolicyV2Rules, SecretHosts, + AvailableApps, ConnectionProviders, PolicyRuleV2Row, PolicyV2Rules, PrincipalSet, SecretHosts, }; use crate::gateway::{strip_port, ProxyContext}; -use crate::policy::{check_rate_limit, MatchedRule, PolicyDecision}; +use crate::policy::{check_rate_limit, MatchInput, MatchedRule, PolicyDecision}; use super::assemble::assemble; use super::evaluate::evaluate_outcome; -use super::types::{Action, Outcome, Request, Rule}; +use super::loaders; +use super::types::{Action, Outcome, Request, Rule, RuleScope}; -/// `false` always: OSS's `condition_match` arm cannot buffer bodies and never -/// evaluates conditions (they match vacuously), so there is nothing to buffer for. -pub(crate) fn needs_body_buffer(_v2: &PolicyV2Rules) -> bool { - false +/// True iff a loaded rule (org or project) has a BODY condition on a target +/// that could govern this `host` — the host-scoped superset that keeps the +/// buffering as narrow as correctness allows. A network target matches its +/// own `host_pattern`; app/connection/secret targets buffer unconditionally +/// (their host resolution lives in the catalog/fenced maps — not worth +/// duplicating here); unknown kinds never match anything. Header-only +/// conditions never buffer (headers are always available); equipment rows are +/// injection-only and skipped; empty slices never buffer. +/// +/// The superset law: `needs_body_buffer` must be TRUE whenever some body +/// condition could be consulted for this host, so the matcher only ever sees +/// `body: None` for a request that genuinely had no body — never for one whose +/// body was skipped by the streaming path. +pub(crate) fn needs_body_buffer(v2: &PolicyV2Rules, host: &str) -> bool { + let host = strip_port(host); + v2.org + .iter() + .chain(v2.project.iter()) + .filter(|r| r.source != "equipment") + .filter(|r| crate::condition_match::has_body_condition(&r.conditions)) + .any(|r| { + r.targets.0.iter().any(|t| match t.kind.as_str() { + "network" => t + .host_pattern + .as_deref() + .is_some_and(|p| crate::connect::host_matches(host, p)), + "app" | "connection" | "secret" => true, + _ => false, + }) + }) } -/// Equipment rows are excluded: they are injection-only (dropped by the -/// assembler), so their secret/connection targets never need host/provider -/// resolution — mirroring the EE loader's lazy skip, which keeps the common -/// selective-agent connect resolution free of the two extra queries. -fn has_target_kind(rows: &[PolicyRuleV2Row], kind: &str) -> bool { - rows.iter() +/// True when any loaded rule (org or project) has a target of `kind`, skipping +/// equipment rows (injection-only — dropped by the assembler, so their +/// secret/connection targets never need host/provider resolution). The lazy +/// gate that keeps the common connect resolution free of the two extra queries. +fn has_target_kind(levels: &[&[PolicyRuleV2Row]], kind: &str) -> bool { + levels + .iter() + .flat_map(|rows| rows.iter()) .filter(|r| r.source != "equipment") .any(|r| r.targets.0.iter().any(|t| t.kind == kind)) } -/// Load the published project rules at resolution time — cached with -/// `ConnectResponse`, off the per-request hot path. Secret hosts and connection -/// providers resolve lazily, only when some loaded rule needs them. Any load error -/// PROPAGATES: the caller refuses the CONNECT rather than caching a policy-free -/// (allow-everything, inject-nothing) state for the ~60s cache cycle. +/// Load the published org + project rules (and lazily the principal set) at +/// resolution time — cached with `ConnectResponse`, off the per-request hot +/// path. Principals, secret hosts, and connection providers resolve lazily, +/// only when some loaded rule needs them. Any load error PROPAGATES: the caller +/// refuses the CONNECT rather than caching a policy-free state for the ~60s +/// cache cycle. pub(crate) async fn load_connect_v2( pool: &PgPool, org_id: &str, project_id: &str, ) -> anyhow::Result { + let org = loaders::find_published_policy_rules_v2_by_org(pool, org_id) + .await + .context("policy v2: org load failed at resolution")?; let project = find_published_policy_rules_v2_by_project(pool, project_id) .await .context("policy v2: project load failed at resolution")?; - let secret_hosts = if has_target_kind(&project, "secret") { + // Principals resolve lazily: only when some loaded rule (org or project, + // equipment included — inject-selection matches against them too) carries a + // directory identity. The set is agent-independent, so `agent_id` is not a + // parameter. The common agent-only connect stays at zero extra queries. + let principals = if loaders::has_directory_identity(&[&org, &project]) { + loaders::load_principal_set(pool, org_id, project_id) + .await + .context("policy v2: principal resolution failed at resolution")? + } else { + PrincipalSet::default() + }; + let secret_hosts = if has_target_kind(&[&org, &project], "secret") { find_secret_hosts(pool, org_id, project_id) .await .context("policy v2: secret-host resolution failed at resolution")? } else { SecretHosts::default() }; - let connection_providers = if has_target_kind(&project, "connection") { + let connection_providers = if has_target_kind(&[&org, &project], "connection") { find_connection_providers(pool, org_id, project_id) .await .context("policy v2: connection-provider resolution failed at resolution")? @@ -67,10 +116,11 @@ pub(crate) async fn load_connect_v2( ConnectionProviders::default() }; Ok(PolicyV2Rules { + org, project, + principals, secret_hosts, connection_providers, - ..PolicyV2Rules::default() }) } @@ -122,17 +172,16 @@ async fn decision_for_rule( PolicyDecision::Allow } -/// Decide via the OSS core over the already-resolved project rules. No DB access. -/// If the identity is somehow incomplete, or the rule set is empty (a load error, -/// or a project with no published policy), the decision is `Allow` — the engine is -/// authoritative, so there is no fallback. +/// Decide via the OSS two-level core over the already-resolved org + project +/// rules. No DB access. If the identity is somehow incomplete, the decision is +/// `Allow` — the engine is authoritative, so there is no fallback. #[allow(clippy::too_many_arguments)] pub(crate) async fn evaluate( proxy_ctx: &ProxyContext, host: &str, method: &str, path: &str, - body: Option<&[u8]>, + input: &MatchInput<'_>, has_injections: bool, is_llm_host: bool, winning_connection_id: Option<&str>, @@ -148,7 +197,18 @@ pub(crate) async fn evaluate( }; let agent_token = proxy_ctx.agent_token.as_deref().unwrap_or(""); - let rules = assemble(&v2.project, &v2.secret_hosts, &v2.connection_providers); + let org_rules = assemble( + &v2.org, + RuleScope::Organization, + &v2.secret_hosts, + &v2.connection_providers, + ); + let project_rules = assemble( + &v2.project, + RuleScope::Project, + &v2.secret_hosts, + &v2.connection_providers, + ); let request = Request { host: strip_port(host).to_string(), path: path.to_string(), @@ -162,9 +222,9 @@ pub(crate) async fn evaluate( let matched_of = |rule: &Rule| MatchedRule { logical_id: rule.logical_id.clone(), name: rule.name.clone(), - scope: "project".to_string(), + scope: rule.scope.as_str().to_string(), }; - match evaluate_outcome(&rules, &request, body) { + match evaluate_outcome(&org_rules, &project_rules, &request, &v2.principals, input) { Outcome::Rule(rule) => ( decision_for_rule(rule, org_id, project_id, agent_token, cache).await, Some(matched_of(rule)), @@ -176,3 +236,201 @@ pub(crate) async fn evaluate( Outcome::Allow => (PolicyDecision::Allow, None), } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use sqlx::types::Json; + + fn row(over: impl FnOnce(&mut PolicyRuleV2Row)) -> PolicyRuleV2Row { + let mut r = PolicyRuleV2Row { + id: "r1".to_string(), + logical_id: "l1".to_string(), + name: "rule".to_string(), + source: "custom".to_string(), + priority: 0, + is_default: false, + action: "allow".to_string(), + rate_limit: None, + rate_limit_window: None, + require_approval: false, + conditions: None, + identities: Json(Vec::new()), + targets: Json(Vec::new()), + }; + over(&mut r); + r + } + + fn proxy_ctx() -> ProxyContext { + ProxyContext { + project_id: Some("p1".to_string()), + organization_id: Some("o1".to_string()), + agent_id: Some("a1".to_string()), + agent_name: None, + agent_identifier: None, + agent_token: Some("t".to_string()), + } + } + + fn network_target() -> serde_json::Value { + json!({"kind": "network", "hostPattern": "api.example.com"}) + } + + /// An org rule scoped to a directory GROUP, plus a project Default Rule. + fn org_group_block_bundle(principals: PrincipalSet) -> PolicyV2Rules { + PolicyV2Rules { + org: vec![row(|r| { + r.action = "block".to_string(); + r.identities = Json( + serde_json::from_value(json!([ + {"agentId": null, "userId": null, "groupId": "g1"} + ])) + .expect("identity rows"), + ); + r.targets = Json(vec![ + serde_json::from_value(network_target()).expect("target row") + ]); + })], + project: vec![row(|r| r.is_default = true)], + principals, + ..PolicyV2Rules::default() + } + } + + async fn run_seam(v2: &PolicyV2Rules) -> (PolicyDecision, Option) { + let store = crate::cache::create_store().await.expect("store"); + evaluate( + &proxy_ctx(), + "api.example.com", + "GET", + "/", + &MatchInput::empty(), + false, + false, + None, + store.as_ref(), + v2, + ) + .await + } + + /// Test #1/#3: the seam wires `&v2.org` → the org assemble, `&v2.principals` + /// → the evaluator (a g1 membership matches), and `matched_of` attributes + /// the org scope end-to-end. + #[tokio::test] + async fn evaluate_enforces_an_org_group_rule_through_the_seam() { + let v2 = org_group_block_bundle(PrincipalSet { + group_ids: vec!["g1".to_string()], + ..PrincipalSet::default() + }); + let (decision, matched) = run_seam(&v2).await; + assert!(matches!(decision, PolicyDecision::Blocked { .. })); + let m = matched.expect("the winning org rule must be attributed"); + assert_eq!(m.scope, "organization"); + } + + /// The companion regression: an EMPTY principal set narrows the same org + /// group rule to nothing → Allow. A `PrincipalSet::default()` wired into the + /// seam would flip the test above, never this one. + #[tokio::test] + async fn empty_principals_narrow_the_org_group_rule_to_nothing() { + let v2 = org_group_block_bundle(PrincipalSet::default()); + let (decision, matched) = run_seam(&v2).await; + assert!(matches!(decision, PolicyDecision::Allow)); + assert!(matched.is_none()); + } + + #[test] + fn has_target_kind_scans_org_and_project_and_skips_equipment() { + let org = vec![row(|r| { + r.targets = Json(vec![serde_json::from_value( + json!({"kind": "secret", "secretId": "s1"}), + ) + .expect("target row")]); + })]; + let project: Vec = Vec::new(); + assert!(has_target_kind(&[&org, &project], "secret")); + assert!(!has_target_kind(&[&org, &project], "connection")); + // Equipment rows stay excluded — they are injection-only. + let equipment = vec![row(|r| { + r.source = "equipment".to_string(); + r.targets = Json(vec![serde_json::from_value( + json!({"kind": "secret", "secretId": "s1"}), + ) + .expect("target row")]); + })]; + assert!(!has_target_kind(&[&equipment, &project], "secret")); + } + + #[test] + fn needs_body_buffer_scopes_to_host_and_skips_equipment() { + let body_cond: Option = + serde_json::from_str(r#"[{"target":"body","operator":"contains","value":"x"}]"#).ok(); + let network_rule = |conditions: Option| { + row(|r| { + r.conditions = conditions; + r.targets = Json(vec![serde_json::from_value( + json!({"kind": "network", "hostPattern": "api.example.com"}), + ) + .expect("target row")]); + }) + }; + // Network-target rule with a body condition: only its host buffers + // (port-stripped), foreign hosts keep streaming. + let v2 = PolicyV2Rules { + project: vec![network_rule(body_cond.clone())], + ..PolicyV2Rules::default() + }; + assert!(needs_body_buffer(&v2, "api.example.com")); + assert!(needs_body_buffer(&v2, "api.example.com:443")); + assert!(!needs_body_buffer(&v2, "other.example.com")); + // Org-scope rules count too. + let v2 = PolicyV2Rules { + org: vec![network_rule(body_cond.clone())], + ..PolicyV2Rules::default() + }; + assert!(needs_body_buffer(&v2, "api.example.com")); + // App-target rule → conservatively buffer everywhere (superset law). + let app_rule = row(|r| { + r.conditions = body_cond.clone(); + r.targets = Json(vec![serde_json::from_value( + json!({"kind": "app", "appProvider": "github", "appTools": []}), + ) + .expect("target row")]); + }); + let v2 = PolicyV2Rules { + project: vec![app_rule], + ..PolicyV2Rules::default() + }; + assert!(needs_body_buffer(&v2, "anything.example.com")); + // Equipment rows are injection-only — never buffer. + let equipment = row(|r| { + r.source = "equipment".to_string(); + r.conditions = body_cond.clone(); + r.targets = Json(vec![serde_json::from_value( + json!({"kind": "network", "hostPattern": "api.example.com"}), + ) + .expect("target row")]); + }); + let v2 = PolicyV2Rules { + project: vec![equipment], + ..PolicyV2Rules::default() + }; + assert!(!needs_body_buffer(&v2, "api.example.com")); + // Header-only conditions never buffer (headers are always available). + let header_cond: Option = + serde_json::from_str(r#"[{"target":"header","operator":"exists","key":"x-k"}]"#).ok(); + let v2 = PolicyV2Rules { + project: vec![network_rule(header_cond)], + ..PolicyV2Rules::default() + }; + assert!(!needs_body_buffer(&v2, "api.example.com")); + // Empty bundle never buffers. + assert!(!needs_body_buffer( + &PolicyV2Rules::default(), + "api.example.com" + )); + } +} diff --git a/apps/gateway/src/policy_engine/evaluate.rs b/apps/gateway/src/policy_engine/evaluate.rs index 46511ebd..36b0ccd7 100644 --- a/apps/gateway/src/policy_engine/evaluate.rs +++ b/apps/gateway/src/policy_engine/evaluate.rs @@ -1,44 +1,71 @@ -//! The OSS first-match evaluator: ONE level (project), the single-level -//! reduction of the uniform per-level law — the first matching rule decides, -//! else the project Default Rule is the terminal (its Block gated by the -//! `enforce_deny` carve), else allow. +//! The OSS two-level first-match evaluator, mirroring the canonical +//! `policy-translation/evaluator.ts` (`evaluatePolicyOutcome`): per-scope +//! first-match (org, then project), combined by STRICTEST (block strictest … +//! allow loosest), with each level's Default Rule as its fallback verdict +//! (deny wins), PLUS the HARD-FLOOR rule — a lone ALLOW at one level cannot +//! open the OTHER level's default-Block. Org-first tie-break. +//! +//! Why two levels rather than one merged list: a project rule may shadow a +//! project sibling, but must NEVER override an org guardrail. A single merged +//! first-match can honor at most one of "identity beats strictness" and "org is +//! un-overridable"; splitting org/project and combining by strictest honors both. //! //! Matching routes through the gateway's own `connect::host_matches` + //! `policy::matches_request`, so path globs, methods, the git-receive-pack -//! bridge, and the (no-op in OSS) condition arm are byte-identical to the -//! legacy path. +//! bridge, and the body/header condition arm are byte-identical to the shared +//! matcher. Conditions are evaluated at BOTH scopes: every rule's own +//! Block-ness rides through the pseudo-rule seam, so an unevaluable condition +//! fails CLOSED by action (a Block over-blocks, an Allow falls through). -use crate::policy::{matches_request, PolicyAction, PolicyRule}; +use crate::db::PrincipalSet; +use crate::policy::{matches_request, MatchInput, PolicyAction, PolicyRule}; -use super::types::{Identity, Outcome, Request, Rule, Target}; +use super::types::{Action, Identity, Outcome, Request, Rule, Target}; -/// Empty identities = "any agent"; an `Agent` identity matches by id; `Other` -/// (a stored directory identity) never matches. -fn identity_matches(rule: &Rule, request: &Request) -> bool { +/// Empty identities = "any"; an `Agent` identity matches the acting agent by +/// id; the directory kinds (`User`/`Group`) match against the connection's +/// resolved principal set; `Other` (a row naming no principal the OSS engine +/// understands) never matches. Linear scans are fine — principal sets are small. +fn identity_matches(rule: &Rule, request: &Request, principals: &PrincipalSet) -> bool { rule.identities.is_empty() || rule.identities.iter().any(|i| match i { Identity::Agent(id) => *id == request.agent_id, + Identity::User(id) => principals.user_ids.contains(id), + Identity::Group(id) => principals.group_ids.contains(id), Identity::Other => false, }) } /// A throwaway `policy::PolicyRule` so the network match runs the gateway's -/// exact `matches_request` (the action is irrelevant to matching). +/// exact `matches_request`. Conditions ride from the owning rule, and so does +/// its BLOCK-ness (`is_block`): `condition_match`'s failure law is +/// action-aware — an unevaluable condition fails CLOSED only for a Block rule +/// — so hardcoding Allow here would fail a v2 Block rule OPEN on a broken +/// regex/oversized body. The owning rule's NAME rides along too, so the +/// matcher's unevaluable-condition warning identifies the broken rule instead +/// of logging an empty name. fn pseudo_rule( + name: &str, path_pattern: Option<&str>, method: Option, conditions: &Option, + is_block: bool, ) -> PolicyRule { PolicyRule { - name: String::new(), + name: name.to_string(), path_pattern: path_pattern.unwrap_or("*").to_string(), method, - action: PolicyAction::Allow, + action: if is_block { + PolicyAction::Block + } else { + PolicyAction::Allow + }, conditions_raw: conditions.clone(), } } -fn target_matches(target: &Target, rule: &Rule, request: &Request, body: Option<&[u8]>) -> bool { +fn target_matches(target: &Target, rule: &Rule, request: &Request, input: &MatchInput<'_>) -> bool { + let is_block = rule.action == Action::Block; match target { Target::Network { host_pattern, @@ -47,24 +74,33 @@ fn target_matches(target: &Target, rule: &Rule, request: &Request, body: Option< } => { crate::connect::host_matches(&request.host, host_pattern) && matches_request( - &pseudo_rule(path_pattern.as_deref(), method.clone(), &rule.conditions), + &pseudo_rule( + &rule.name, + path_pattern.as_deref(), + method.clone(), + &rule.conditions, + is_block, + ), &request.method, &request.path, - body, + input, ) } Target::App { provider, tools } => super::catalog::app_target_matches( + &rule.name, provider, tools, &request.host, &request.method, &request.path, - body, + input, &rule.conditions, + is_block, ), // A connection target matches only when it is the request's winning // injected connection AND the provider/tools fan-out hits. No winner → - // never matches (fail-closed for allow AND block). + // never matches (fail-closed for allow AND block). Conditions ride + // through the fan-out carrying the owning rule's Block-ness. Target::Connection { id, provider, @@ -72,20 +108,34 @@ fn target_matches(target: &Target, rule: &Rule, request: &Request, body: Option< } => { request.winning_connection_id.as_deref() == Some(id.as_str()) && super::catalog::app_target_matches( + &rule.name, provider, tools, &request.host, &request.method, &request.path, - body, + input, &rule.conditions, + is_block, + ) + } + // A secret target gates its resolved host(s). Empty patterns + // (unresolved/deleted secret) never match — fail-closed. The owning + // rule's conditions still narrow the match (wildcard-path pseudo-rule + // carrying its Block-ness): without this gate a conditioned ALLOW on a + // secret would match unconditionally and could shadow a later Block — + // the widening the fail-closed law forbids. + Target::Secret { host_patterns } => { + host_patterns + .iter() + .any(|h| crate::connect::host_matches(&request.host, h)) + && matches_request( + &pseudo_rule(&rule.name, None, None, &rule.conditions, is_block), + &request.method, + &request.path, + input, ) } - // A secret target gates its resolved host(s), host-only. Empty patterns - // (unresolved/deleted secret) never match — fail-closed. - Target::Secret { host_patterns } => host_patterns - .iter() - .any(|h| crate::connect::host_matches(&request.host, h)), Target::Unresolved => false, } } @@ -94,44 +144,142 @@ fn target_matches(target: &Target, rule: &Rule, request: &Request, body: Option< /// them matches. Empty targets = matches NOTHING: "match everything" is the /// Default Rule's job, never an empty list — which also neutralizes a rule /// orphaned to zero targets by an FK cascade (fail-closed). -fn rule_matches(rule: &Rule, request: &Request, body: Option<&[u8]>) -> bool { - identity_matches(rule, request) +fn rule_matches( + rule: &Rule, + request: &Request, + principals: &PrincipalSet, + input: &MatchInput<'_>, +) -> bool { + identity_matches(rule, request, principals) && !rule.targets.is_empty() && rule .targets .iter() - .any(|t| target_matches(t, rule, request, body)) + .any(|t| target_matches(t, rule, request, input)) } -/// First matching non-default rule in `(priority, id)` order. The id tie-break -/// makes equal priorities total and deterministic, agreeing with the DB's -/// `ORDER BY r.priority, r.id` (ids are lowercase-hex UUIDs, so Rust byte order -/// equals the Postgres collation). -fn first_match<'a>(rules: &'a [Rule], request: &Request, body: Option<&[u8]>) -> Option<&'a Rule> { +/// Strictness rank, mirroring `strictness.ts::strictnessRank`: block strictest +/// (0) … allow loosest (3). LOWER is stricter, so the reduce below keeps the +/// smaller rank. (A rate-limit modifier ranks by its presence alone, exactly +/// as the TS does — `rateLimit !== null`.) +fn strictness_rank(rule: &Rule) -> u8 { + if rule.action == Action::Block { + 0 + } else if rule.require_approval { + 1 + } else if rule.rate_limit.is_some() { + 2 + } else { + 3 + } +} + +/// A level's first matching non-default rule, carrying its strictness rank. +#[derive(Clone, Copy)] +struct LevelMatch<'a> { + rank: u8, + rule: &'a Rule, +} + +/// First matching non-default rule of one level in `(priority, id)` order. The +/// id tie-break makes equal priorities total and deterministic, agreeing with +/// the DB's `ORDER BY r.priority, r.id` (ids are lowercase-hex UUIDs, so Rust +/// byte order equals the Postgres collation). +fn first_match<'a>( + rules: &'a [Rule], + request: &Request, + principals: &PrincipalSet, + input: &MatchInput<'_>, +) -> Option> { let mut ordered: Vec<&'a Rule> = rules.iter().filter(|r| !r.is_default).collect(); ordered.sort_by(|a, b| a.priority.cmp(&b.priority).then_with(|| a.id.cmp(&b.id))); ordered .into_iter() - .find(|rule| rule_matches(rule, request, body)) + .find(|rule| rule_matches(rule, request, principals, input)) + .map(|rule| LevelMatch { + rank: strictness_rank(rule), + rule, + }) } -/// Decide the request: the first matching rule wins (allow or block — an -/// explicit project allow opens its own Default-Block, allowlist-style); -/// otherwise the project Default Rule is the terminal, its Block enforced only -/// under the `enforce_deny` carve (credentialed, non-LLM traffic); otherwise -/// allow. This is exactly the EE evaluator's project arm with no org level -/// contributing a verdict. +/// Decide the request under the two-level hard-floor law, a faithful port of +/// `evaluator.ts::evaluatePolicyOutcome`: +/// +/// - each level's verdict is its first matching explicit rule (else nothing); +/// - a Default-Block is a HARD FLOOR at its level (gated by the `enforce_deny` +/// carve): a lone ALLOW at the OTHER level is DROPPED so it can't open it — +/// an org allow can't punch through a project allowlist floor, and a project +/// allow can't punch through an org default-Block; a BLOCK still applies (it +/// only tightens); +/// - surviving matches combine by STRICTEST (lower rank wins), org-first on a +/// tie (the org rate/approval modifier wins); +/// - with no surviving match the level defaults decide, deny-wins, org-first. +/// +/// Only ONE rule ever decides — modifiers never stack across levels. pub(super) fn evaluate_outcome<'a>( - rules: &'a [Rule], + org_rules: &'a [Rule], + project_rules: &'a [Rule], request: &Request, - body: Option<&[u8]>, + principals: &PrincipalSet, + input: &MatchInput<'_>, ) -> Outcome<'a> { - if let Some(rule) = first_match(rules, request, body) { - return Outcome::Rule(rule); + let org_default = org_rules.iter().find(|r| r.is_default); + let project_default = project_rules.iter().find(|r| r.is_default); + + let org_match = first_match(org_rules, request, principals, input); + let project_match = first_match(project_rules, request, principals, input); + + // A Default-Block is enforced only under the carve (credentialed, non-LLM), + // at EVERY level. + let enforce_deny = request.enforce_deny(); + let org_default_blocks = org_default.is_some_and(|d| d.action == Action::Block) && enforce_deny; + let project_default_blocks = + project_default.is_some_and(|d| d.action == Action::Block) && enforce_deny; + + // A lone org ALLOW can't punch through the project default-Block (allowlist + // mode) — drop it so it falls through to the deny-default. An org BLOCK + // still applies (it only tightens). Approval/rate rules are action "allow", + // so they defer too — symmetric with the org floor below. + let effective_org = if project_match.is_none() + && matches!(org_match, Some(m) if m.rule.action == Action::Allow) + && project_default_blocks + { + None + } else { + org_match + }; + + // A lone project ALLOW can't punch through the org default-Block — drop it + // so it falls through to the deny-default. A project BLOCK still applies (it + // only tightens); an allow-posture org lets the project allow win. + let effective_project = if org_match.is_none() + && matches!(project_match, Some(m) if m.rule.action == Action::Allow) + && org_default_blocks + { + None + } else { + project_match + }; + + // Combine by strictest (lower rank = stricter); on a tie keep the org match + // (left bias) so the org modifier wins, matching the oracle's org-first pass. + let best = [effective_org, effective_project] + .into_iter() + .flatten() + .reduce(|a, b| if b.rank < a.rank { b } else { a }); + if let Some(best) = best { + return Outcome::Rule(best.rule); } - let default = rules.iter().find(|r| r.is_default); - if let Some(d) = default { - if d.action == super::types::Action::Block && request.enforce_deny() { + + // No explicit rule survived → the level defaults decide; deny wins, + // attributed org-first (the org default is checked first at the gateway). + if org_default_blocks { + if let Some(d) = org_default { + return Outcome::DenyDefault(d); + } + } + if project_default_blocks { + if let Some(d) = project_default { return Outcome::DenyDefault(d); } } @@ -140,12 +288,13 @@ pub(super) fn evaluate_outcome<'a>( #[cfg(test)] mod tests { - use super::super::types::{Action, RateWindow}; + use super::super::types::{Action, RateWindow, RuleScope}; use super::*; fn rule(id: &str, priority: usize, action: Action) -> Rule { Rule { id: id.to_string(), + scope: RuleScope::Project, logical_id: format!("l-{id}"), name: id.to_string(), priority, @@ -164,6 +313,26 @@ mod tests { } } + fn org_rule(id: &str, priority: usize, action: Action) -> Rule { + Rule { + scope: RuleScope::Organization, + ..rule(id, priority, action) + } + } + + fn approval_rule(id: &str, priority: usize) -> Rule { + let mut r = rule(id, priority, Action::Allow); + r.require_approval = true; + r + } + + fn rate_rule(id: &str, priority: usize) -> Rule { + let mut r = rule(id, priority, Action::Allow); + r.rate_limit = Some(5); + r.rate_limit_window = Some(RateWindow::Minute); + r + } + fn default_rule(action: Action) -> Rule { let mut r = rule("default", 99, action); r.is_default = true; @@ -171,6 +340,24 @@ mod tests { r } + fn org_default(action: Action) -> Rule { + Rule { + scope: RuleScope::Organization, + ..default_rule(action) + } + } + + fn no_principals() -> PrincipalSet { + PrincipalSet::default() + } + + fn principals() -> PrincipalSet { + PrincipalSet { + user_ids: vec!["u-1".to_string()], + group_ids: vec!["g-1".to_string()], + } + } + fn request() -> Request { Request { host: "api.example.com".to_string(), @@ -190,62 +377,18 @@ mod tests { } } - /// The per-account law, all four directions: a `Connection` target matches - /// iff (the request's winning injected connection == its id) AND the - /// provider catalog fan-out hits. Lockstep twin of the EE corpus arms - /// 6b/6c/11/12 and the TS `connection target binds to the winner` block. - #[test] - fn connection_target_binds_to_the_winning_connection() { - let conn_block = |id: &str| { - let mut r = rule("c-rule", 1, Action::Block); - r.targets = vec![Target::Connection { - id: id.to_string(), - provider: "gmail".to_string(), - tools: Vec::new(), - }]; - r - }; - let req_via = |winner: Option<&str>| Request { - host: "gmail.googleapis.com".to_string(), - path: "/gmail/v1/users/me/messages".to_string(), - method: "GET".to_string(), - agent_id: "agent-1".to_string(), - has_injections: true, - is_llm_host: false, - winning_connection_id: winner.map(str::to_string), - }; - let rules = vec![conn_block("c1")]; - - // Matching winner on the provider's catalog host → the block binds. - assert!(matches!( - evaluate_outcome(&rules, &req_via(Some("c1")), None), - Outcome::Rule(r) if r.action == Action::Block - )); - // A same-provider sibling account → no match (the deliberate change - // from the provider-wide decode). - assert!(matches!( - evaluate_outcome(&rules, &req_via(Some("c2")), None), - Outcome::Allow - )); - // No winner (secret-served / uncredentialed) → no match (fail-closed). - assert!(matches!( - evaluate_outcome(&rules, &req_via(None), None), - Outcome::Allow - )); - // Winner equality alone is not enough: a host outside the provider's - // catalog fails the fan-out gate. - let mut off_host = req_via(Some("c1")); - off_host.host = "api.github.com".to_string(); - assert!(matches!( - evaluate_outcome(&rules, &off_host, None), - Outcome::Allow - )); - } + // ── Single-level (project) reductions — the org slice is empty ────── #[test] fn first_match_wins_by_priority() { let rules = vec![rule("b", 1, Action::Block), rule("a", 0, Action::Allow)]; - match evaluate_outcome(&rules, &request(), None) { + match evaluate_outcome( + &[], + &rules, + &request(), + &no_principals(), + &MatchInput::empty(), + ) { Outcome::Rule(r) => assert_eq!(r.id, "a"), _ => panic!("expected a rule match"), } @@ -257,13 +400,20 @@ mod tests { vec![rule("a", 5, Action::Allow), rule("b", 5, Action::Block)], vec![rule("b", 5, Action::Block), rule("a", 5, Action::Allow)], ] { - match evaluate_outcome(&rules, &request(), None) { + match evaluate_outcome( + &[], + &rules, + &request(), + &no_principals(), + &MatchInput::empty(), + ) { Outcome::Rule(r) => assert_eq!(r.id, "a", "lower id wins the tie"), _ => panic!("expected a rule match"), } } } + /// Test #11 (part): `Other` never matches; empty identities = any. #[test] fn agent_identity_scopes_and_other_never_matches() { let mut agent_scoped = rule("scoped", 0, Action::Block); @@ -273,40 +423,73 @@ mod tests { let allow = rule("any", 2, Action::Allow); let rules = vec![agent_scoped, other, allow]; - match evaluate_outcome(&rules, &request(), None) { + match evaluate_outcome( + &[], + &rules, + &request(), + &no_principals(), + &MatchInput::empty(), + ) { Outcome::Rule(r) => assert_eq!(r.id, "scoped"), _ => panic!("expected the agent-scoped match"), } let mut foreign = request(); foreign.agent_id = "agent-2".to_string(); - match evaluate_outcome(&rules, &foreign, None) { + match evaluate_outcome( + &[], + &rules, + &foreign, + &no_principals(), + &MatchInput::empty(), + ) { // The directory identity must NOT match — the any-agent allow wins. Outcome::Rule(r) => assert_eq!(r.id, "any"), _ => panic!("expected the any-agent match"), } } + /// Test #11 (part): an empty-target rule is inert. #[test] fn empty_target_rule_is_inert() { let mut orphan = rule("orphan", 0, Action::Block); orphan.targets = Vec::new(); let control = rule("control", 1, Action::Allow); - match evaluate_outcome(&[orphan, control], &request(), None) { + match evaluate_outcome( + &[], + &[orphan, control], + &request(), + &no_principals(), + &MatchInput::empty(), + ) { Outcome::Rule(r) => assert_eq!(r.id, "control"), _ => panic!("expected the control match"), } } + /// Test #10 (project level): the Default Rule Block enforces only under the + /// `enforce_deny` carve. #[test] fn default_block_enforces_only_under_the_carve() { let rules = vec![default_rule(Action::Block)]; // Uncredentialed → the carve spares it. assert!(matches!( - evaluate_outcome(&rules, &request(), None), + evaluate_outcome( + &[], + &rules, + &request(), + &no_principals(), + &MatchInput::empty() + ), Outcome::Allow )); // Credentialed non-LLM → blocked, attributed to the Default Rule. - match evaluate_outcome(&rules, &injected_request(), None) { + match evaluate_outcome( + &[], + &rules, + &injected_request(), + &no_principals(), + &MatchInput::empty(), + ) { Outcome::DenyDefault(d) => assert!(d.is_default), _ => panic!("expected the deny-default"), } @@ -314,17 +497,23 @@ mod tests { let mut llm = injected_request(); llm.is_llm_host = true; assert!(matches!( - evaluate_outcome(&rules, &llm, None), + evaluate_outcome(&[], &rules, &llm, &no_principals(), &MatchInput::empty()), Outcome::Allow )); } #[test] - fn explicit_allow_opens_the_default_block() { + fn explicit_allow_opens_the_same_level_default_block() { let rules = vec![rule("open", 0, Action::Allow), default_rule(Action::Block)]; - match evaluate_outcome(&rules, &injected_request(), None) { + match evaluate_outcome( + &[], + &rules, + &injected_request(), + &no_principals(), + &MatchInput::empty(), + ) { Outcome::Rule(r) => assert_eq!(r.id, "open"), - _ => panic!("expected the allow rule to win over the default block"), + _ => panic!("expected the allow rule to win over its own default block"), } } @@ -332,26 +521,741 @@ mod tests { fn default_allow_is_neutral() { let rules = vec![default_rule(Action::Allow)]; assert!(matches!( - evaluate_outcome(&rules, &injected_request(), None), + evaluate_outcome( + &[], + &rules, + &injected_request(), + &no_principals(), + &MatchInput::empty() + ), Outcome::Allow )); } + // ── Stage-G: conditions are evaluated at both scopes ──────────────── + + fn conditioned(id: &str, priority: usize, action: Action, conditions: &str) -> Rule { + let mut r = rule(id, priority, action); + r.conditions = serde_json::from_str(conditions).ok(); + r + } + + fn body_input(body: &[u8]) -> MatchInput<'_> { + MatchInput { + body: Some(body), + body_truncated: false, + headers: None, + } + } + #[test] - fn conditioned_rule_matches_with_no_body_in_oss() { - // OSS's condition arm is the no-op (vacuously true) — a conditioned - // block matches exactly like the legacy OSS gateway treated it. This - // pins the posture; if OSS ever ships real condition matching, this - // test must flip with it. - let mut conditioned = rule("cond", 0, Action::Block); - conditioned.conditions = serde_json::from_str( - r#"[{"target":"body","operator":"contains","value":"never-present"}]"#, - ) - .ok(); - match evaluate_outcome(&[conditioned], &request(), None) { + fn conditioned_block_falls_through_when_body_lacks_the_needle() { + // OSS evaluates conditions since Tier 3a (this test used to pin the + // opposite no-op posture): a body-conditioned block whose needle is + // absent falls through and the next rule wins. + let rules = vec![ + conditioned( + "cond", + 0, + Action::Block, + r#"[{"target":"body","operator":"contains","value":"needle"}]"#, + ), + rule("open", 1, Action::Allow), + ]; + match evaluate_outcome( + &[], + &rules, + &request(), + &no_principals(), + &body_input(b"no match here"), + ) { + Outcome::Rule(r) => assert_eq!(r.id, "open"), + _ => panic!("expected the conditioned block to fall through"), + } + } + + #[test] + fn conditioned_block_matches_when_body_contains_the_needle() { + let rules = vec![ + conditioned( + "cond", + 0, + Action::Block, + r#"[{"target":"body","operator":"contains","value":"needle"}]"#, + ), + rule("open", 1, Action::Allow), + ]; + match evaluate_outcome( + &[], + &rules, + &request(), + &no_principals(), + &body_input(b"the needle is here"), + ) { Outcome::Rule(r) => assert_eq!(r.id, "cond"), - _ => panic!("expected the conditioned rule to match vacuously"), + _ => panic!("expected the conditioned block to match"), + } + } + + #[test] + fn invalid_condition_on_a_v2_block_still_blocks() { + // Pins the pseudo-rule action mapping: the failure law is action-aware, + // so a Block rule with an uncompilable regex must still BLOCK. This + // test fails if `pseudo_rule` hardcodes Allow. + let rules = vec![ + conditioned( + "broken", + 0, + Action::Block, + r#"[{"target":"body","operator":"regex","value":"(?<=x)["}]"#, + ), + rule("open", 1, Action::Allow), + ]; + match evaluate_outcome( + &[], + &rules, + &request(), + &no_principals(), + &body_input(b"anything"), + ) { + Outcome::Rule(r) => assert_eq!(r.id, "broken", "Block must fail CLOSED"), + _ => panic!("expected the broken-condition block to match"), + } + // The symmetric guard: the same broken condition on an ALLOW rule + // falls through (it must not shadow a later block). + let rules = vec![ + conditioned( + "broken-allow", + 0, + Action::Allow, + r#"[{"target":"body","operator":"regex","value":"(?<=x)["}]"#, + ), + rule("blocker", 1, Action::Block), + ]; + match evaluate_outcome( + &[], + &rules, + &request(), + &no_principals(), + &body_input(b"anything"), + ) { + Outcome::Rule(r) => assert_eq!(r.id, "blocker"), + _ => panic!("expected the broken-condition allow to fall through"), + } + } + + #[test] + fn conditions_are_enforced_at_the_org_scope_too() { + // The fail-closed-by-action law holds at BOTH scopes (F routes org and + // project through the same seam): an org-scope Block whose broken regex + // is unevaluable fails CLOSED, over-blocking even a project allow. + let org = vec![{ + let mut r = conditioned( + "org-broken", + 0, + Action::Block, + r#"[{"target":"body","operator":"regex","value":"("}]"#, + ); + r.scope = RuleScope::Organization; + r + }]; + let project = vec![rule("proj-allow", 0, Action::Allow)]; + match evaluate_outcome( + &org, + &project, + &request(), + &no_principals(), + &body_input(b"anything"), + ) { + Outcome::Rule(r) => { + assert_eq!(r.id, "org-broken", "org Block must fail closed"); + assert_eq!(r.scope, RuleScope::Organization); + } + _ => panic!("expected the org broken-condition block"), + } + // The same org rule as an ALLOW falls through — its broken condition + // cannot widen or shadow the project block. + let org = vec![{ + let mut r = conditioned( + "org-broken-allow", + 0, + Action::Allow, + r#"[{"target":"body","operator":"regex","value":"("}]"#, + ); + r.scope = RuleScope::Organization; + r + }]; + let project = vec![rule("proj-block", 0, Action::Block)]; + match evaluate_outcome( + &org, + &project, + &request(), + &no_principals(), + &body_input(b"anything"), + ) { + Outcome::Rule(r) => assert_eq!(r.id, "proj-block"), + _ => panic!("the broken org allow must not shadow the project block"), + } + } + + #[test] + fn secret_target_honors_conditions_and_fails_closed_by_action() { + let secret_target = || { + vec![Target::Secret { + host_patterns: vec!["api.example.com".to_string()], + }] + }; + let cond = r#"[{"target":"body","operator":"contains","value":"needle"}]"#; + // A conditioned ALLOW on a secret must NOT match unconditionally — it + // would shadow the later Block (the widening the fail-closed law + // forbids). + let mut cond_allow = conditioned("sec-allow", 0, Action::Allow, cond); + cond_allow.targets = secret_target(); + let mut blocker = rule("blocker", 1, Action::Block); + blocker.targets = secret_target(); + let rules = vec![cond_allow, blocker]; + match evaluate_outcome( + &[], + &rules, + &request(), + &no_principals(), + &body_input(b"no match here"), + ) { + Outcome::Rule(r) => assert_eq!(r.id, "blocker", "allow must fall through"), + _ => panic!("expected the block"), + } + // With the needle present the conditioned allow matches first. + match evaluate_outcome( + &[], + &rules, + &request(), + &no_principals(), + &body_input(b"has needle"), + ) { + Outcome::Rule(r) => assert_eq!(r.id, "sec-allow"), + _ => panic!("expected the conditioned allow"), + } + // An unevaluable condition on a secret-target Block fails CLOSED. + let mut broken_block = conditioned( + "broken", + 0, + Action::Block, + r#"[{"target":"body","operator":"regex","value":"("}]"#, + ); + broken_block.targets = secret_target(); + let rules = vec![broken_block, rule("open", 1, Action::Allow)]; + match evaluate_outcome( + &[], + &rules, + &request(), + &no_principals(), + &body_input(b"anything"), + ) { + Outcome::Rule(r) => assert_eq!(r.id, "broken", "Block must fail closed"), + _ => panic!("expected the broken-condition block"), + } + } + + #[test] + fn header_condition_narrows_a_v2_rule() { + let rules = vec![ + conditioned( + "hdr", + 0, + Action::Block, + r#"[{"target":"header","operator":"equals","key":"X-Env","value":"prod"}]"#, + ), + rule("open", 1, Action::Allow), + ]; + let mut headers = hyper::HeaderMap::new(); + headers.insert("x-env", hyper::header::HeaderValue::from_static("prod")); + let input = MatchInput { + body: None, + body_truncated: false, + headers: Some(&headers), + }; + match evaluate_outcome(&[], &rules, &request(), &no_principals(), &input) { + Outcome::Rule(r) => assert_eq!(r.id, "hdr", "matching header must block"), + _ => panic!("expected the header-conditioned block"), + } + let mut other = hyper::HeaderMap::new(); + other.insert("x-env", hyper::header::HeaderValue::from_static("dev")); + let input = MatchInput { + body: None, + body_truncated: false, + headers: Some(&other), + }; + match evaluate_outcome(&[], &rules, &request(), &no_principals(), &input) { + Outcome::Rule(r) => assert_eq!(r.id, "open", "non-matching header falls through"), + _ => panic!("expected the allow"), + } + } + + // ── Test #7: connection winner-binding, fail-closed both ways ─────── + + /// A `Connection` target matches iff (winner == its id) AND the catalog + /// fan-out hits — for an ALLOW and a BLOCK alike; no winner → never matches. + #[test] + fn connection_target_binds_to_the_winning_connection() { + let conn_rule = |id: &str, action: Action| { + let mut r = rule("c-rule", 1, action); + r.targets = vec![Target::Connection { + id: id.to_string(), + provider: "gmail".to_string(), + tools: Vec::new(), + }]; + r + }; + let req_via = |winner: Option<&str>| Request { + host: "gmail.googleapis.com".to_string(), + path: "/gmail/v1/users/me/messages".to_string(), + method: "GET".to_string(), + agent_id: "agent-1".to_string(), + has_injections: true, + is_llm_host: false, + winning_connection_id: winner.map(str::to_string), + }; + + // BLOCK: matching winner binds; no winner → no match (fail-closed). + let blk = vec![conn_rule("c1", Action::Block)]; + assert!(matches!( + evaluate_outcome(&[], &blk, &req_via(Some("c1")), &no_principals(), &MatchInput::empty()), + Outcome::Rule(r) if r.action == Action::Block + )); + assert!(matches!( + evaluate_outcome( + &[], + &blk, + &req_via(None), + &no_principals(), + &MatchInput::empty() + ), + Outcome::Allow + )); + // A same-provider sibling account → no match. + assert!(matches!( + evaluate_outcome( + &[], + &blk, + &req_via(Some("c2")), + &no_principals(), + &MatchInput::empty() + ), + Outcome::Allow + )); + + // ALLOW: an allow-connection rule over a project default-Block only + // opens the door for its OWN winner; no winner → the default-Block + // stands (fail-closed for allow too). + let allow_over_block = vec![conn_rule("c1", Action::Allow), default_rule(Action::Block)]; + match evaluate_outcome( + &[], + &allow_over_block, + &req_via(Some("c1")), + &no_principals(), + &MatchInput::empty(), + ) { + Outcome::Rule(r) => assert_eq!(r.action, Action::Allow), + _ => panic!("winner should open its own connection allow"), } + assert!(matches!( + evaluate_outcome( + &[], + &allow_over_block, + &req_via(None), + &no_principals(), + &MatchInput::empty() + ), + Outcome::DenyDefault(_) + )); + } + + // ── Test #1: an org-scope rule is enforced and attributed ─────────── + + #[test] + fn org_rule_is_enforced_and_carries_org_scope() { + let org = vec![org_rule("org-block", 0, Action::Block)]; + match evaluate_outcome( + &org, + &[], + &request(), + &no_principals(), + &MatchInput::empty(), + ) { + Outcome::Rule(r) => { + assert_eq!(r.id, "org-block"); + assert_eq!(r.scope, RuleScope::Organization); + } + _ => panic!("expected the org block"), + } + } + + // ── Tests #2/#3/#4: directory identities via the principal set ────── + + #[test] + fn user_and_group_identities_match_via_the_principal_set() { + for (id, identity) in [ + ("by-user", Identity::User("u-1".to_string())), + ("by-group", Identity::Group("g-1".to_string())), + ] { + let mut scoped = org_rule(id, 0, Action::Block); + scoped.identities = vec![identity]; + let org = vec![scoped]; + // Present in the principal set → the rule matches. + match evaluate_outcome(&org, &[], &request(), &principals(), &MatchInput::empty()) { + Outcome::Rule(r) => assert_eq!(r.id, id), + _ => panic!("expected {id} to match via principals"), + } + // Absent (empty/stale set) → the rule narrows to nothing. + assert!(matches!( + evaluate_outcome( + &org, + &[], + &request(), + &no_principals(), + &MatchInput::empty() + ), + Outcome::Allow + )); + } + } + + /// Test #4: cross-org isolation at the match boundary — a rule naming a + /// principal absent from THIS connection's set (it belongs to another org's + /// directory, so the org-fenced loader never put it here) never matches. + #[test] + fn a_principal_outside_the_resolved_set_never_matches() { + let mut foreign_user = org_rule("foreign-user", 0, Action::Block); + foreign_user.identities = vec![Identity::User("u-other".to_string())]; + let mut foreign_group = org_rule("foreign-group", 1, Action::Block); + foreign_group.identities = vec![Identity::Group("g-other".to_string())]; + let org = vec![foreign_user, foreign_group]; + assert!(matches!( + evaluate_outcome(&org, &[], &request(), &principals(), &MatchInput::empty()), + Outcome::Allow + )); + } + + // ── Test #5: EMPTY-ORG FAIL-OPEN ──────────────────────────────────── + + /// An empty org slice must contribute NO verdict — never a phantom block. + /// Most orgs have zero org rules (the boot converter writes project-scope + /// only), so this is the load-bearing safety property. + #[test] + fn empty_org_fails_open_not_closed() { + // No project rules either → plain allow, even credentialed. + assert!(matches!( + evaluate_outcome( + &[], + &[], + &injected_request(), + &no_principals(), + &MatchInput::empty() + ), + Outcome::Allow + )); + // An empty org slice changes nothing vs the project-only walk. + let project = vec![rule("open", 0, Action::Allow), default_rule(Action::Block)]; + match evaluate_outcome( + &[], + &project, + &injected_request(), + &no_principals(), + &MatchInput::empty(), + ) { + Outcome::Rule(r) => assert_eq!(r.id, "open"), + _ => panic!("expected the project allow, not a phantom org block"), + } + } + + // ── Test #6: empty project → the org level decides ────────────────── + + #[test] + fn empty_project_lets_the_org_level_decide() { + let org = vec![org_rule("org-allow", 0, Action::Allow)]; + match evaluate_outcome( + &org, + &[], + &request(), + &no_principals(), + &MatchInput::empty(), + ) { + Outcome::Rule(r) => { + assert_eq!(r.id, "org-allow"); + assert_eq!(r.scope, RuleScope::Organization); + } + _ => panic!("expected the org allow to decide"), + } + // An org default-Block over an empty project blocks under the carve. + let org = vec![org_default(Action::Block)]; + match evaluate_outcome( + &org, + &[], + &injected_request(), + &no_principals(), + &MatchInput::empty(), + ) { + Outcome::DenyDefault(d) => assert_eq!(d.scope, RuleScope::Organization), + _ => panic!("expected the org deny-default"), + } + } + + // ── Test #8: two-level stricter-wins ──────────────────────────────── + + #[test] + fn org_block_overrides_project_allow_and_vice_versa() { + // Org guardrail Block beats a project allow… + let org = vec![org_rule("org-block", 0, Action::Block)]; + let project = vec![rule("proj-allow", 0, Action::Allow)]; + match evaluate_outcome( + &org, + &project, + &request(), + &no_principals(), + &MatchInput::empty(), + ) { + Outcome::Rule(r) => assert_eq!(r.id, "org-block"), + _ => panic!("expected the org block"), + } + // …and symmetrically a project Block survives an org allow. + let org = vec![org_rule("org-allow", 0, Action::Allow)]; + let project = vec![rule("proj-block", 0, Action::Block)]; + match evaluate_outcome( + &org, + &project, + &request(), + &no_principals(), + &MatchInput::empty(), + ) { + Outcome::Rule(r) => assert_eq!(r.id, "proj-block"), + _ => panic!("expected the project block"), + } + } + + #[test] + fn org_approval_beats_project_rate_limit() { + let org = vec![{ + let mut r = approval_rule("org-approval", 0); + r.scope = RuleScope::Organization; + r + }]; + let project = vec![rate_rule("proj-rate", 0)]; + match evaluate_outcome( + &org, + &project, + &request(), + &no_principals(), + &MatchInput::empty(), + ) { + Outcome::Rule(r) => assert_eq!(r.id, "org-approval"), + _ => panic!("expected the approval to outrank the rate limit"), + } + } + + #[test] + fn equal_rank_rate_limits_attribute_to_the_org_rule() { + // Two rate verdicts: only the winner acts, and the equal-rank tie goes + // to org (left bias). + let org = vec![{ + let mut r = rate_rule("org-rate", 0); + r.scope = RuleScope::Organization; + r + }]; + let project = vec![rate_rule("proj-rate", 0)]; + match evaluate_outcome( + &org, + &project, + &request(), + &no_principals(), + &MatchInput::empty(), + ) { + Outcome::Rule(r) => { + assert_eq!(r.id, "org-rate"); + assert_eq!(r.scope, RuleScope::Organization); + } + _ => panic!("expected the org rate rule"), + } + } + + /// Test #11 (part): a level's `Other`-only rule is inert, the empty-identity + /// rule at that level still fires, and both levels honor "any". + #[test] + fn empty_identities_match_any_at_both_levels_and_other_never_does() { + let mut malformed = org_rule("malformed", 0, Action::Block); + malformed.identities = vec![Identity::Other]; + let org = vec![malformed, org_rule("org-any", 1, Action::Block)]; + let project = vec![rule("proj-any", 0, Action::Allow)]; + match evaluate_outcome( + &org, + &project, + &request(), + &principals(), + &MatchInput::empty(), + ) { + Outcome::Rule(r) => assert_eq!(r.id, "org-any"), + _ => panic!("expected the any-identity org block"), + } + } + + // ── Test #9: the HARD FLOOR, both directions ──────────────────────── + + /// A lone project ALLOW cannot open the org default-Block; a lone org ALLOW + /// cannot open the project allowlist default-Block. Under the carve both + /// fall through to the respective deny-default. + #[test] + fn a_lone_allow_cannot_open_the_other_levels_default_block() { + // Direction 1: org default-Block + lone project allow → org deny-default. + let org = vec![org_default(Action::Block)]; + let project = vec![rule("proj-allow", 0, Action::Allow)]; + match evaluate_outcome( + &org, + &project, + &injected_request(), + &no_principals(), + &MatchInput::empty(), + ) { + Outcome::DenyDefault(d) => { + assert!(d.is_default); + assert_eq!(d.scope, RuleScope::Organization); + } + _ => panic!("the project allow must not punch the org floor"), + } + // Without the carve the org level allows — the project allow wins. + match evaluate_outcome( + &org, + &project, + &request(), + &no_principals(), + &MatchInput::empty(), + ) { + Outcome::Rule(r) => assert_eq!(r.id, "proj-allow"), + _ => panic!("expected the project allow off the carve"), + } + + // Direction 2: project default-Block (allowlist) + lone org allow → + // project deny-default. + let org = vec![org_rule("org-allow", 0, Action::Allow)]; + let project = vec![default_rule(Action::Block)]; + match evaluate_outcome( + &org, + &project, + &injected_request(), + &no_principals(), + &MatchInput::empty(), + ) { + Outcome::DenyDefault(d) => { + assert!(d.is_default); + assert_eq!(d.scope, RuleScope::Project); + } + _ => panic!("the org allow must not punch the project allowlist floor"), + } + // Without the carve the project level allows — the org allow wins. + match evaluate_outcome( + &org, + &project, + &request(), + &no_principals(), + &MatchInput::empty(), + ) { + Outcome::Rule(r) => assert_eq!(r.id, "org-allow"), + _ => panic!("expected the org allow off the carve"), + } + } + + /// The counter-case: a BLOCK is never dropped by the floor logic (it only + /// tightens), and an allow-posture opposite level lets the allow through. + #[test] + fn a_block_survives_the_floor_and_an_allow_posture_lets_an_allow_win() { + // A project BLOCK applies even against an org default-Block… + let org = vec![org_default(Action::Block)]; + let project = vec![rule("proj-block", 0, Action::Block)]; + match evaluate_outcome( + &org, + &project, + &injected_request(), + &no_principals(), + &MatchInput::empty(), + ) { + Outcome::Rule(r) => assert_eq!(r.id, "proj-block"), + _ => panic!("a block must survive the org floor"), + } + // …and with no org default-Block a lone org allow just wins. + let org = vec![org_rule("org-allow", 0, Action::Allow)]; + match evaluate_outcome( + &org, + &[], + &injected_request(), + &no_principals(), + &MatchInput::empty(), + ) { + Outcome::Rule(r) => assert_eq!(r.id, "org-allow"), + _ => panic!("expected the org allow"), + } + } + + // ── Test #10: deny-default carve per level ────────────────────────── + + #[test] + fn org_default_block_carve_gates_each_level_independently() { + // Org default-Block: spared off the carve, blocks under it. + let org = vec![org_default(Action::Block)]; + assert!(matches!( + evaluate_outcome( + &org, + &[], + &request(), + &no_principals(), + &MatchInput::empty() + ), + Outcome::Allow + )); + match evaluate_outcome( + &org, + &[], + &injected_request(), + &no_principals(), + &MatchInput::empty(), + ) { + Outcome::DenyDefault(d) => assert_eq!(d.scope, RuleScope::Organization), + _ => panic!("expected the org deny-default under the carve"), + } + // Project default-Block: same carve, independently. + let project = vec![default_rule(Action::Block)]; + assert!(matches!( + evaluate_outcome( + &[], + &project, + &request(), + &no_principals(), + &MatchInput::empty() + ), + Outcome::Allow + )); + match evaluate_outcome( + &[], + &project, + &injected_request(), + &no_principals(), + &MatchInput::empty(), + ) { + Outcome::DenyDefault(d) => assert_eq!(d.scope, RuleScope::Project), + _ => panic!("expected the project deny-default under the carve"), + } + } + + #[test] + fn absent_project_default_with_org_default_allow_is_allow() { + let org = vec![org_default(Action::Allow)]; + assert!(matches!( + evaluate_outcome( + &org, + &[], + &injected_request(), + &no_principals(), + &MatchInput::empty() + ), + Outcome::Allow + )); } #[test] diff --git a/apps/gateway/src/policy_engine/loaders.rs b/apps/gateway/src/policy_engine/loaders.rs new file mode 100644 index 00000000..2dfcc4be --- /dev/null +++ b/apps/gateway/src/policy_engine/loaders.rs @@ -0,0 +1,200 @@ +//! The OSS analogs of the EE overlay loaders: the org-scope published-rule +//! query and the connection's principal-set resolution. OSS-only by +//! construction — the EE builds swap the whole `policy_engine` tree (with +//! their own loaders) via `#[path]` in `main.rs`, so nothing here can collide +//! with the enterprise overlay on merge. +//! +//! Both run ONCE at connection resolution (cached with `ConnectResponse`); +//! the per-request decision path never touches the DB. + +use anyhow::{Context, Result}; +use sqlx::PgPool; + +use crate::db::{PolicyRuleV2Row, PrincipalSet, POLICY_V2_SELECT}; + +/// Active published ORG-scope rules (max published generation), first-match +/// ordered. Mirrors `db::find_published_policy_rules_v2_by_project` exactly — +/// same SELECT, same generation law, same `ORDER BY priority, id` — with the +/// org arm's fence (`organization_id` + `scope = 'organization'`). +pub(super) async fn find_published_policy_rules_v2_by_org( + pool: &PgPool, + organization_id: &str, +) -> Result> { + sqlx::query_as::<_, PolicyRuleV2Row>(&format!( + r#"{POLICY_V2_SELECT} + WHERE r.organization_id = $1 AND r.scope = 'organization' + AND r.status = 'published' AND r.enabled = true + AND r.generation = ( + SELECT max(generation) FROM policy_rules_v2 + WHERE organization_id = $1 AND scope = 'organization' AND status = 'published') + ORDER BY r.priority, r.id"# + )) + .bind(organization_id) + .fetch_all(pool) + .await + .context("querying org policy_rules_v2 by organization_id") +} + +/// One resolved principal set: the two text[] columns of the CTE below. +#[derive(sqlx::FromRow)] +struct PrincipalRow { + user_ids: Vec, + group_ids: Vec, +} + +/// Resolve the connection's principal set — the humans a proxied request is +/// matched against, and the directory groups they carry. Proxied traffic bears +/// no connecting-user identity (`ProxyContext` is agent-only), so the set is +/// AGENT-INDEPENDENT: one resolution covers every agent of the project. A pure +/// mirror of `resolvePrincipalSet` +/// (packages/api/src/services/policy-simulate/principal-set.ts): +/// +/// - `direct_users` = ProjectAccess rows naming a user; +/// - `direct_groups` = ProjectAccess rows naming a group, ORG-FENCED FIRST +/// (a granted group must belong to this org); +/// - `candidate_users` = direct_users ∪ members of the (org-fenced) direct_groups; +/// - `user_ids` = candidate_users ∩ ACTIVE org members (status <> 'suspended', +/// mirroring the people-gate `user_can_manage_project`); +/// - `group_ids` = direct_groups ∪ every group the resolved user_ids belong to, +/// the latter ORG-FENCED (a user can belong to OTHER orgs' groups). +/// +/// Every arm is org-fenced, so a foreign group grant or a user's membership in +/// another org's groups can never leak in. Role-agnostic (presence-only). Run +/// as ONE indexed CTE round-trip (off the hot path — the gateway resolves this +/// at connect, cached with `ConnectResponse`). Keep in lockstep with the TS. +pub(super) async fn load_principal_set( + pool: &PgPool, + organization_id: &str, + project_id: &str, +) -> Result { + let row: PrincipalRow = sqlx::query_as::<_, PrincipalRow>( + r#" + WITH access AS ( + SELECT user_id, group_id FROM project_access WHERE project_id = $1 + ), + direct_users AS ( + SELECT user_id FROM access WHERE user_id IS NOT NULL + ), + direct_groups AS ( + SELECT g.id FROM groups g + WHERE g.id IN (SELECT group_id FROM access WHERE group_id IS NOT NULL) + AND g.organization_id = $2 + ), + candidate_users AS ( + SELECT user_id FROM direct_users + UNION + SELECT gm.user_id FROM group_members gm + WHERE gm.group_id IN (SELECT id FROM direct_groups) + ), + active_users AS ( + SELECT om.user_id FROM organization_members om + WHERE om.user_id IN (SELECT user_id FROM candidate_users) + AND om.organization_id = $2 + AND om.status <> 'suspended' + ), + user_groups AS ( + SELECT gm.group_id FROM group_members gm + JOIN groups g ON g.id = gm.group_id AND g.organization_id = $2 + WHERE gm.user_id IN (SELECT user_id FROM active_users) + ) + SELECT + COALESCE((SELECT array_agg(DISTINCT user_id) FROM active_users), '{}') AS user_ids, + COALESCE(( + SELECT array_agg(DISTINCT gid) FROM ( + SELECT id AS gid FROM direct_groups + UNION + SELECT group_id AS gid FROM user_groups + ) g + ), '{}') AS group_ids + "#, + ) + .bind(project_id) + .bind(organization_id) + .fetch_one(pool) + .await + .context("resolving the connection principal set")?; + + Ok(PrincipalSet { + user_ids: row.user_ids, + group_ids: row.group_ids, + }) +} + +/// True when any loaded rule (org or project, every source — equipment rows +/// matter for inject-selection) carries a directory identity row (user or +/// group). The lazy gate on principal resolution: agent-only configs skip the +/// resolution query entirely. There is no agent-group column, so only user/group +/// rows trigger it. +pub(super) fn has_directory_identity(levels: &[&[PolicyRuleV2Row]]) -> bool { + levels.iter().flat_map(|rows| rows.iter()).any(|r| { + r.identities + .0 + .iter() + .any(|i| i.user_id.is_some() || i.group_id.is_some()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use sqlx::types::Json; + + fn row(identities: serde_json::Value, source: &str) -> PolicyRuleV2Row { + PolicyRuleV2Row { + id: "r1".to_string(), + logical_id: "l1".to_string(), + name: "rule".to_string(), + source: source.to_string(), + priority: 0, + is_default: false, + action: "allow".to_string(), + rate_limit: None, + rate_limit_window: None, + require_approval: false, + conditions: None, + identities: Json(serde_json::from_value(identities).expect("identities")), + targets: Json(Vec::new()), + } + } + + fn identity(v: serde_json::Value) -> serde_json::Value { + json!([v]) + } + + #[test] + fn agent_only_rows_do_not_trigger_principal_resolution() { + let rows = vec![ + row(json!([]), "custom"), + row( + identity(json!({"agentId": "a1", "userId": null, "groupId": null})), + "custom", + ), + ]; + assert!(!has_directory_identity(&[&rows, &[]])); + } + + #[test] + fn each_directory_kind_triggers_principal_resolution() { + for principal in [ + json!({"agentId": null, "userId": "u1", "groupId": null}), + json!({"agentId": null, "userId": null, "groupId": "g1"}), + ] { + let rows = vec![row(identity(principal), "custom")]; + assert!(has_directory_identity(&[&rows, &[]])); + } + } + + #[test] + fn scans_both_levels_and_counts_equipment_rows() { + let org: Vec = Vec::new(); + // An equipment row's directory identity matters (inject-selection reads + // equipment rows), so it must trigger resolution too. + let project = vec![row( + identity(json!({"agentId": null, "userId": null, "groupId": "g1"})), + "equipment", + )]; + assert!(has_directory_identity(&[&org, &project])); + assert!(!has_directory_identity(&[&org, &[]])); + } +} diff --git a/apps/gateway/src/policy_engine/scope.rs b/apps/gateway/src/policy_engine/scope.rs new file mode 100644 index 00000000..8b8a948d --- /dev/null +++ b/apps/gateway/src/policy_engine/scope.rs @@ -0,0 +1,1195 @@ +//! Granular resource-scope enforcement (Tier 3b). +//! +//! A connection may carry a per-agent *granular session policy* that confines +//! the injected credential to specific resources — a GitHub connection limited +//! to certain repositories, a Dropbox connection limited to certain folders. +//! The API validates and stores it (`agent_app_connections.session_policy`, +//! the `sessionPolicySchema` union `{repositories:[…]}` | `{folders:[…]}`) and +//! the gateway resolves it once per request into +//! `ResolvedRules.session_policy`. This module is the enforcement the OSS build +//! previously lacked: it parses that value and, per provider, extracts the +//! resource a request addresses, then TIGHTENS the already-computed policy +//! decision — an allow-family verdict for an out-of-scope or indeterminate +//! resource becomes `Blocked`; an existing block is returned untouched. It is a +//! monotone tightening (`final = max(engine_verdict, scope_verdict)`, Block the +//! strictest), so it composes with the first-match / stricter-wins engine law +//! and can never widen what the rules closed. +//! +//! ## Provider coverage +//! +//! Exactly the two providers the DB/API/UI model: +//! +//! - **GitHub** (`github-app`, `github`): `{repositories:["owner/repo", …]}`. +//! The repo is read from the URL path — `/repos/{owner}/{repo}` on +//! `api.github.com`, `/{owner}/{repo}(.git)?/…` for git-over-HTTPS and raw +//! content on any other GitHub host. Case-insensitive (GitHub repo names are). +//! - **Dropbox** (`dropbox`): `{folders:["/path", …]}`. The folder is read from +//! the request JSON — the buffered body on `api.dropboxapi.com` RPC endpoints, +//! the `Dropbox-API-Arg` header on `content.dropboxapi.com`. A request is in +//! scope iff every path it names is equal to, or a descendant of, an allowed +//! folder (segment-boundary prefix match; case-insensitive). +//! +//! Every other provider, GitHub GraphQL / numeric `/repositories/{id}`, and any +//! resource axis other than repositories/folders are **not** covered — they hit +//! the fail-closed indeterminate arm below. The web `granularAccessConfigs` +//! register the same two providers, so there is no authoring surface for an +//! axis this build cannot extract. +//! +//! ## Fail-closed (SECURITY) +//! +//! When a scope is set and the requested resource cannot be positively verified +//! in scope, the request is DENIED. Indeterminate covers: an unparseable / +//! numeric / GraphQL GitHub repo reference; a missing, unparseable, absent, or +//! truncated Dropbox arg; a malformed session-policy object (unknown key / wrong +//! value types); a scope whose shape does not match its provider; and any +//! provider this build does not understand while a scope is present. The *only* +//! allow paths are: no scope at all (`parse → None`, the gate is a no-op — the +//! overwhelmingly common case), a positively verified in-scope resource, or an +//! endpoint positively classified as not resource-addressed (GitHub +//! account/search/meta endpoints; Dropbox RPC account endpoints). + +use serde_json::Value; + +use crate::gateway::strip_port; +use crate::policy::{MatchInput, PolicyDecision}; + +/// A parsed granular session policy. `Malformed` marks a scope that is present +/// but garbled (unknown key, non-string list, both keys, extra keys); it maps +/// to `Indeterminate` at evaluation so a garbled scope never reads as "all". +#[derive(Debug, PartialEq, Eq)] +enum ResourceScope { + Repositories(Vec), + Folders(Vec), + Malformed, +} + +/// The per-request scope verdict. `Indeterminate` is the fail-closed arm: a +/// scope is set but the resource cannot be determined. +#[derive(Debug, PartialEq, Eq)] +enum ScopeVerdict { + InScope, + OutOfScope, + Indeterminate, +} + +/// Parse a stored `session_policy` value into a scope, mirroring the API +/// `sessionPolicySchema`. Returns `None` for "no scope" — an empty/absent +/// object, an empty `repositories`/`folders` list, `null`, or any non-object +/// (all mean "all resources", so the gate is a no-op). Returns +/// `Some(Malformed)` for a scope-present-but-garbled object. +fn parse(sp: &Value) -> Option { + let obj = sp.as_object()?; // non-object → unscoped + if obj.is_empty() { + return None; // {} → all + } + let has_repos = obj.contains_key("repositories"); + let has_folders = obj.contains_key("folders"); + match (has_repos, has_folders, obj.len()) { + // Exactly one recognized key, nothing else — the strict union shape. + (true, false, 1) => Some(match string_list(&obj["repositories"]) { + ListShape::Values(v) => ResourceScope::Repositories(v), + ListShape::Empty => return None, // empty list = all + ListShape::Malformed => ResourceScope::Malformed, + }), + (false, true, 1) => Some(match string_list(&obj["folders"]) { + ListShape::Values(v) => ResourceScope::Folders(v), + ListShape::Empty => return None, + ListShape::Malformed => ResourceScope::Malformed, + }), + // Unknown key, both keys, or extra keys alongside a recognized one. + _ => Some(ResourceScope::Malformed), + } +} + +enum ListShape { + Values(Vec), + Empty, + Malformed, +} + +/// A JSON value must be an array of strings. A non-array, or any non-string +/// element, is malformed (fail-closed); an empty array is "all". +fn string_list(v: &Value) -> ListShape { + let Some(arr) = v.as_array() else { + return ListShape::Malformed; + }; + if arr.is_empty() { + return ListShape::Empty; + } + let mut out = Vec::with_capacity(arr.len()); + for el in arr { + match el.as_str() { + Some(s) => out.push(s.to_string()), + None => return ListShape::Malformed, + } + } + ListShape::Values(out) +} + +/// Whether the request body must be buffered for scope extraction. True only +/// for a `dropbox` connection with a non-empty `{folders}` policy on the RPC +/// host `api.dropboxapi.com` (the folder rides in the JSON body). GitHub is +/// URL-only and `content.dropboxapi.com` reads the `Dropbox-API-Arg` header, so +/// neither buffers — critically, the content host must NOT buffer, its body is +/// the uploaded/downloaded file, not the folder argument. +pub(crate) fn needs_body(provider: &str, host: &str, session_policy: Option<&Value>) -> bool { + provider == "dropbox" + && strip_port(host) == "api.dropboxapi.com" + && matches!( + session_policy.and_then(parse), + Some(ResourceScope::Folders(_)) + ) +} + +/// Tighten an already-computed policy decision by the connection's granular +/// resource scope. An existing Block (rule or default) is returned untouched — +/// scope never re-attributes or loosens a denial. Otherwise an out-of-scope or +/// indeterminate resource maps the allow-family verdict (Allow / ManualApproval +/// / RateLimited) to `Blocked { rule_name: "resource scope" }`. The returned +/// bool is `scope_blocked`, so the caller can drop rule attribution (the block +/// is scope-authored, not rule-authored). +pub(crate) fn apply_resource_scope( + decision: PolicyDecision, + provider: &str, + host: &str, + session_policy: Option<&Value>, + path: &str, + input: &MatchInput<'_>, +) -> (PolicyDecision, bool) { + // Already denied → never loosen, never re-attribute. + if matches!( + decision, + PolicyDecision::Blocked { .. } | PolicyDecision::BlockedByDefaultPolicy + ) { + return (decision, false); + } + match evaluate_scope(provider, host, session_policy, path, input) { + ScopeVerdict::InScope => (decision, false), + ScopeVerdict::OutOfScope | ScopeVerdict::Indeterminate => ( + PolicyDecision::Blocked { + rule_name: "resource scope".to_string(), + }, + true, + ), + } +} + +/// The pure verdict: does this request address a resource the scope allows? No +/// scope present → `InScope` (the gate is a no-op). Dispatch is by provider AND +/// validates the scope shape matches (github ⇒ Repositories, dropbox ⇒ Folders); +/// any mismatch, a `Malformed` scope, or an unknown provider carrying a scope → +/// `Indeterminate` (a scope authored for an axis this build cannot extract must +/// never pass). +fn evaluate_scope( + provider: &str, + host: &str, + session_policy: Option<&Value>, + path: &str, + input: &MatchInput<'_>, +) -> ScopeVerdict { + let scope = match session_policy.and_then(parse) { + None => return ScopeVerdict::InScope, // unscoped → no-op + Some(s) => s, + }; + match (provider, scope) { + ("github-app" | "github", ResourceScope::Repositories(allowed)) => { + github_scope(strip_port(host), path, &allowed) + } + ("dropbox", ResourceScope::Folders(allowed)) => dropbox_scope(host, path, input, &allowed), + _ => ScopeVerdict::Indeterminate, + } +} + +// ── Path traversal (SECURITY) ─────────────────────────────────────────────── + +/// Whether a single `/`-delimited segment is a `.`/`..` dot-segment, including +/// its percent-encoded forms (`%2e`, `%2E`, `%2e%2e`, …). Decoded lossily so a +/// non-UTF-8 segment simply fails to match rather than panicking. +fn is_dot_segment(seg: &str) -> bool { + let decoded = percent_encoding::percent_decode_str(seg).decode_utf8_lossy(); + decoded == "." || decoded == ".." +} + +/// Whether a path contains any dot-segment. The forwarding layer builds the +/// upstream URL with the `url` crate, which collapses `.`/`..` (and their +/// `%2e` encodings) per WHATWG *before* the request is sent, so a scope check +/// run on the RAW request path would extract a resource from a path GitHub +/// never sees. Any such path is therefore treated as unverifiable (fail closed) +/// rather than parsed at face value. +fn has_traversal(path: &str) -> bool { + path.split('/').any(is_dot_segment) +} + +// ── GitHub ──────────────────────────────────────────────────────────────── + +/// What repository, if any, a GitHub request path addresses. +enum RepoRef { + /// `owner`, `repo` (repo case-folded at compare time). + Repo(String, String), + /// Account/search/meta endpoint — cannot name an out-of-scope repo. + NotRepoAddressed, + /// Repo-addressed but unverifiable at the URL layer (numeric id, GraphQL, + /// a `/repos/` prefix we cannot split) — fail closed. + Indeterminate, +} + +fn github_scope(host: &str, path: &str, allowed: &[String]) -> ScopeVerdict { + match github_repo_ref(host, path) { + RepoRef::Repo(owner, repo) => { + if repo_in_scope(&owner, &repo, allowed) { + ScopeVerdict::InScope + } else { + ScopeVerdict::OutOfScope + } + } + RepoRef::NotRepoAddressed => ScopeVerdict::InScope, + RepoRef::Indeterminate => ScopeVerdict::Indeterminate, + } +} + +fn github_repo_ref(host: &str, path: &str) -> RepoRef { + // Drop query / fragment before splitting. + let path = path.split(['?', '#']).next().unwrap_or(path); + // A dot-segment (`.`/`..`, raw or `%2e`-encoded) is collapsed by the + // forwarding layer's URL builder before the request reaches GitHub, so the + // repo we would extract here is not the repo that gets served. Fail closed. + if has_traversal(path) { + return RepoRef::Indeterminate; + } + let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + + if host == "api.github.com" { + match segs.first().copied() { + Some("repos") => match (segs.get(1), segs.get(2)) { + (Some(owner), Some(repo)) if !owner.is_empty() && !repo.is_empty() => { + RepoRef::Repo((*owner).to_string(), (*repo).to_string()) + } + // `/repos` or `/repos/{owner}` — repo-prefixed, no repo named. + _ => RepoRef::Indeterminate, + }, + // Numeric legacy id and GraphQL name the repo somewhere we can't + // confine at the URL layer. + Some("repositories") => RepoRef::Indeterminate, + Some("graphql") => RepoRef::Indeterminate, + // Everything else (`/user*`, `/orgs*`, `/search*`, `/rate_limit`, + // `/installation/repositories`, `/meta`, root, …) cannot name an + // out-of-scope repo (enumeration is bounded by GitHub's repo-scoped + // installation token server-side). + _ => RepoRef::NotRepoAddressed, + } + } else { + // git-over-HTTPS (`github.com`) and raw content + // (`raw.githubusercontent.com`): `/{owner}/{repo}(.git)?/…`. + match (segs.first(), segs.get(1)) { + (Some(owner), Some(repo)) if !owner.is_empty() && !repo.is_empty() => { + let repo = repo.strip_suffix(".git").unwrap_or(repo); + if repo.is_empty() { + RepoRef::Indeterminate + } else { + RepoRef::Repo((*owner).to_string(), repo.to_string()) + } + } + // Fewer than two segments (root, `/settings`, …) → not a repo path. + _ => RepoRef::NotRepoAddressed, + } + } +} + +fn repo_in_scope(owner: &str, repo: &str, allowed: &[String]) -> bool { + let target = format!( + "{}/{}", + owner.to_ascii_lowercase(), + repo.to_ascii_lowercase() + ); + allowed.iter().any(|a| a.to_ascii_lowercase() == target) +} + +// ── Dropbox ───────────────────────────────────────────────────────────────── + +/// The folder path(s) a Dropbox request addresses. +enum PathSet { + /// No resource named — an account / no-arg endpoint. + None, + /// Concrete folder paths (all must be in scope). + Paths(Vec), + /// A path-shaped field is present but not a string, or a batch entry we + /// cannot extract a path from — fail closed. + Unparseable, +} + +fn dropbox_scope( + host: &str, + path: &str, + input: &MatchInput<'_>, + allowed: &[String], +) -> ScopeVerdict { + let host = strip_port(host); + let json = if host == "content.dropboxapi.com" { + // File-content endpoints carry the folder in the `Dropbox-API-Arg` + // header; the body is the file itself and is never buffered. + match dropbox_arg_header(input.headers) { + Some(v) => v, + None => return ScopeVerdict::Indeterminate, + } + } else if host == "api.dropboxapi.com" { + // RPC endpoints carry the folder in the JSON body. + if input.body_truncated { + return ScopeVerdict::Indeterminate; // over-cap → unevaluable + } + match input.body { + // Scoped RPC that reached here unbuffered → fail closed (should not + // happen: `needs_body` buffers these). + None => return ScopeVerdict::Indeterminate, + // A no-arg (empty) body names no folder; whether that is allowed is + // decided by the endpoint allowlist in the `PathSet::None` arm. + Some([]) => Value::Null, + Some(b) => match serde_json::from_slice::(b) { + Ok(v) => v, + Err(_) => return ScopeVerdict::Indeterminate, + }, + } + } else { + // Any other Dropbox host carrying a scope: unrecognized → fail closed. + return ScopeVerdict::Indeterminate; + }; + + match dropbox_paths(&json) { + PathSet::Paths(paths) => { + if paths.iter().all(|p| folder_in_scope(p, allowed)) { + ScopeVerdict::InScope + } else { + ScopeVerdict::OutOfScope + } + } + // No path field found. On the content host every op addresses a + // resource, so a path we could not find is fail-closed. On the RPC host + // a path-less body is in scope ONLY for endpoints known to address no + // folder (account/space/check and `*/continue` cursor pagination); any + // other path-less scoped RPC may address a resource through a field we + // don't parse (e.g. `shared_folder_id`, `options.path`), so it is + // fail-closed — mirroring GitHub's numeric-id / GraphQL treatment. + PathSet::None => { + if host == "api.dropboxapi.com" && is_non_resource_rpc(path) { + ScopeVerdict::InScope + } else { + ScopeVerdict::Indeterminate + } + } + PathSet::Unparseable => ScopeVerdict::Indeterminate, + } +} + +/// Dropbox RPC endpoints that address no folder resource, so a path-less body +/// on them is in scope even while a folder scope is set: the account / space / +/// check endpoints, and `*/continue` cursor-pagination calls (the opaque cursor +/// — obtained from an already-scope-checked listing — identifies the page, not +/// a path). Every other RPC endpoint is treated as potentially +/// resource-addressed and fails closed on a path-less body. +fn is_non_resource_rpc(path: &str) -> bool { + let path = path.split(['?', '#']).next().unwrap_or(path); + let path = path.trim_end_matches('/'); + matches!( + path, + "/2/users/get_current_account" + | "/2/users/get_space_usage" + | "/2/check/user" + | "/2/check/app" + ) || path.ends_with("/continue") +} + +fn dropbox_arg_header(headers: Option<&hyper::HeaderMap>) -> Option { + let s = headers?.get("dropbox-api-arg")?.to_str().ok()?; + serde_json::from_str(s).ok() +} + +/// Extract every folder path a Dropbox arg object names — `path`, `from_path`, +/// `to_path` (move/copy check BOTH), and each `entries[]` element (batch). A +/// non-object arg (e.g. `null` for get_current_account) names no path. +fn dropbox_paths(json: &Value) -> PathSet { + let Some(obj) = json.as_object() else { + return PathSet::None; + }; + let mut paths = Vec::new(); + for key in ["path", "from_path", "to_path"] { + if let Some(v) = obj.get(key) { + match v.as_str() { + Some(s) => paths.push(s.to_string()), + None => return PathSet::Unparseable, + } + } + } + if let Some(entries) = obj.get("entries") { + let Some(arr) = entries.as_array() else { + return PathSet::Unparseable; + }; + for entry in arr { + let Some(eo) = entry.as_object() else { + return PathSet::Unparseable; + }; + let mut found = false; + for key in ["path", "from_path", "to_path"] { + if let Some(v) = eo.get(key) { + match v.as_str() { + Some(s) => { + paths.push(s.to_string()); + found = true; + } + None => return PathSet::Unparseable, + } + } + } + if !found { + // A batch entry we cannot extract a path from — fail closed. + return PathSet::Unparseable; + } + } + } + if paths.is_empty() { + PathSet::None + } else if paths.iter().any(|p| has_traversal(p)) { + // A Dropbox path carrying a `.`/`..` segment cannot be confined by the + // segment-prefix match (`/proj/../evil` prefix-matches `/proj` yet may + // resolve elsewhere), so fail closed. + PathSet::Unparseable + } else { + PathSet::Paths(paths) + } +} + +/// A request folder is in scope iff it equals, or is a descendant of, some +/// allowed folder. Dropbox paths are case-insensitive and `/`-delimited; the +/// prefix match is on whole segments (`/foo` allows `/foo` and `/foo/bar` but +/// not `/foobar`). An allowed entry that normalizes to the root ("") allows +/// everything. +fn folder_in_scope(req: &str, allowed: &[String]) -> bool { + let req = norm_folder(req); + allowed.iter().any(|a| { + let a = norm_folder(a); + a.is_empty() || req == a || req.starts_with(&format!("{a}/")) + }) +} + +fn norm_folder(p: &str) -> String { + p.trim_end_matches('/').to_ascii_lowercase() +} + +// ── Tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn headers(pairs: &[(&str, &str)]) -> hyper::HeaderMap { + let mut map = hyper::HeaderMap::new(); + for (name, value) in pairs { + map.append( + hyper::header::HeaderName::from_bytes(name.as_bytes()).expect("header name"), + hyper::header::HeaderValue::from_str(value).expect("header value"), + ); + } + map + } + + /// A `MatchInput` carrying only a buffered body. + fn body_input(body: &[u8]) -> MatchInput<'_> { + MatchInput { + body: Some(body), + body_truncated: false, + headers: None, + } + } + + // ── parse ──────────────────────────────────────────────────────────── + + #[test] + fn parse_recognizes_the_two_shapes() { + assert_eq!( + parse(&json!({"repositories": ["a/b"]})), + Some(ResourceScope::Repositories(vec!["a/b".to_string()])) + ); + assert_eq!( + parse(&json!({"folders": ["/x"]})), + Some(ResourceScope::Folders(vec!["/x".to_string()])) + ); + } + + #[test] + fn parse_treats_empty_and_absent_as_unscoped() { + assert_eq!(parse(&json!({})), None); + assert_eq!(parse(&json!({"repositories": []})), None); + assert_eq!(parse(&json!({"folders": []})), None); + assert_eq!(parse(&Value::Null), None); + assert_eq!(parse(&json!(["a/b"])), None); // top-level array + assert_eq!(parse(&json!("str")), None); // non-object + } + + #[test] + fn parse_flags_garbled_objects_as_malformed() { + assert_eq!( + parse(&json!({"unknownKey": ["x"]})), + Some(ResourceScope::Malformed) + ); + // Extra key alongside a recognized one. + assert_eq!( + parse(&json!({"repositories": ["a/b"], "folders": ["/x"]})), + Some(ResourceScope::Malformed) + ); + // Non-string list element. + assert_eq!( + parse(&json!({"repositories": [1, 2]})), + Some(ResourceScope::Malformed) + ); + // Value is not a list. + assert_eq!( + parse(&json!({"folders": "/x"})), + Some(ResourceScope::Malformed) + ); + } + + // ── GitHub extraction ──────────────────────────────────────────────── + + fn gh(host: &str, path: &str, allowed: &[&str]) -> ScopeVerdict { + let scope = json!({ "repositories": allowed }); + evaluate_scope("github-app", host, Some(&scope), path, &MatchInput::empty()) + } + + #[test] + fn github_in_and_out_of_scope_by_repo() { + assert_eq!( + gh("api.github.com", "/repos/acme/app/pulls", &["acme/app"]), + ScopeVerdict::InScope + ); + assert_eq!( + gh("api.github.com", "/repos/acme/app/pulls", &["acme/other"]), + ScopeVerdict::OutOfScope + ); + } + + #[test] + fn github_is_case_insensitive() { + assert_eq!( + gh("api.github.com", "/repos/ACME/App", &["acme/app"]), + ScopeVerdict::InScope + ); + } + + #[test] + fn github_git_over_https_path() { + assert_eq!( + gh("github.com", "/acme/app.git/info/refs", &["acme/app"]), + ScopeVerdict::InScope + ); + assert_eq!( + gh("github.com", "/acme/app.git/info/refs", &["acme/other"]), + ScopeVerdict::OutOfScope + ); + } + + #[test] + fn github_raw_content_host_is_repo_addressed() { + assert_eq!( + gh( + "raw.githubusercontent.com", + "/acme/app/main/README.md", + &["acme/app"] + ), + ScopeVerdict::InScope + ); + assert_eq!( + gh( + "raw.githubusercontent.com", + "/acme/secret/main/x", + &["acme/app"] + ), + ScopeVerdict::OutOfScope + ); + } + + #[test] + fn github_account_endpoints_are_in_scope() { + for path in [ + "/user/repos", + "/orgs/acme/repos", + "/rate_limit", + "/", + "/meta", + ] { + assert_eq!( + gh("api.github.com", path, &["acme/app"]), + ScopeVerdict::InScope, + "account endpoint {path} must not be repo-scoped" + ); + } + } + + #[test] + fn github_unverifiable_repo_references_are_indeterminate() { + assert_eq!( + gh("api.github.com", "/repositories/12345", &["acme/app"]), + ScopeVerdict::Indeterminate + ); + assert_eq!( + gh("api.github.com", "/graphql", &["acme/app"]), + ScopeVerdict::Indeterminate + ); + // `/repos/` prefix with no repo named. + assert_eq!( + gh("api.github.com", "/repos/acme", &["acme/app"]), + ScopeVerdict::Indeterminate + ); + } + + #[test] + fn github_dot_segment_traversal_fails_closed() { + // The forwarding layer's URL builder collapses `..` before the request + // reaches GitHub, so a raw path that prefixes an in-scope repo but + // traverses out of it must NOT read as in scope. Both API and git hosts. + assert_eq!( + gh( + "api.github.com", + "/repos/acme/app/../../evil/target/contents/secret", + &["acme/app"] + ), + ScopeVerdict::Indeterminate + ); + // Percent-encoded dot-segments are collapsed identically. + assert_eq!( + gh( + "api.github.com", + "/repos/acme/app/%2e%2e/%2e%2e/evil/repo", + &["acme/app"] + ), + ScopeVerdict::Indeterminate + ); + assert_eq!( + gh( + "github.com", + "/acme/app/../../evil/repo.git/info/refs", + &["acme/app"] + ), + ScopeVerdict::Indeterminate + ); + } + + #[test] + fn dropbox_path_with_dot_segment_fails_closed() { + // `/proj/../evil` prefix-matches `/proj` but resolves elsewhere. + assert_eq!( + dbx_rpc(br#"{"path":"/proj/../evil"}"#, &["/proj"]), + ScopeVerdict::Indeterminate + ); + } + + // ── Dropbox extraction ─────────────────────────────────────────────── + + fn dbx_rpc(body: &[u8], allowed: &[&str]) -> ScopeVerdict { + let scope = json!({ "folders": allowed }); + evaluate_scope( + "dropbox", + "api.dropboxapi.com", + Some(&scope), + "/2/files/list_folder", + &body_input(body), + ) + } + + #[test] + fn dropbox_rpc_in_and_out_of_scope() { + assert_eq!( + dbx_rpc(br#"{"path":"/proj/sub"}"#, &["/proj"]), + ScopeVerdict::InScope + ); + assert_eq!( + dbx_rpc(br#"{"path":"/proj/sub"}"#, &["/other"]), + ScopeVerdict::OutOfScope + ); + // Segment boundary: /projX is not under /proj. + assert_eq!( + dbx_rpc(br#"{"path":"/projX"}"#, &["/proj"]), + ScopeVerdict::OutOfScope + ); + } + + #[test] + fn dropbox_move_checks_all_path_fields() { + assert_eq!( + dbx_rpc( + br#"{"from_path":"/proj/a","to_path":"/other/b"}"#, + &["/proj"] + ), + ScopeVerdict::OutOfScope + ); + assert_eq!( + dbx_rpc( + br#"{"from_path":"/proj/a","to_path":"/proj/b"}"#, + &["/proj"] + ), + ScopeVerdict::InScope + ); + } + + #[test] + fn dropbox_batch_entries_are_checked() { + assert_eq!( + dbx_rpc( + br#"{"entries":[{"from_path":"/proj/a","to_path":"/proj/b"}]}"#, + &["/proj"] + ), + ScopeVerdict::InScope + ); + assert_eq!( + dbx_rpc( + br#"{"entries":[{"from_path":"/proj/a","to_path":"/evil/b"}]}"#, + &["/proj"] + ), + ScopeVerdict::OutOfScope + ); + // An entry with no extractable path is fail-closed. + assert_eq!( + dbx_rpc(br#"{"entries":[{"cursor":"x"}]}"#, &["/proj"]), + ScopeVerdict::Indeterminate + ); + } + + #[test] + fn dropbox_content_host_reads_the_header() { + let scope = json!({ "folders": ["/proj"] }); + let input = MatchInput { + body: None, + body_truncated: false, + headers: Some(&headers(&[( + "dropbox-api-arg", + r#"{"path":"/proj/f.txt"}"#, + )])), + }; + assert_eq!( + evaluate_scope( + "dropbox", + "content.dropboxapi.com", + Some(&scope), + "/2/files/download", + &input + ), + ScopeVerdict::InScope + ); + } + + #[test] + fn dropbox_content_host_out_of_scope_and_missing_header() { + let scope = json!({ "folders": ["/proj"] }); + // Out of scope. + let hit = MatchInput { + body: None, + body_truncated: false, + headers: Some(&headers(&[( + "dropbox-api-arg", + r#"{"path":"/evil/f.txt"}"#, + )])), + }; + assert_eq!( + evaluate_scope( + "dropbox", + "content.dropboxapi.com", + Some(&scope), + "/2/files/download", + &hit + ), + ScopeVerdict::OutOfScope + ); + // No header at all while scoped → fail closed. + let miss = MatchInput { + body: None, + body_truncated: false, + headers: Some(&headers(&[])), + }; + assert_eq!( + evaluate_scope( + "dropbox", + "content.dropboxapi.com", + Some(&scope), + "/2/files/download", + &miss + ), + ScopeVerdict::Indeterminate + ); + // A content op whose arg names no path is fail-closed (every content op + // addresses a resource). + let no_path = MatchInput { + body: None, + body_truncated: false, + headers: Some(&headers(&[("dropbox-api-arg", r#"{"query":"x"}"#)])), + }; + assert_eq!( + evaluate_scope( + "dropbox", + "content.dropboxapi.com", + Some(&scope), + "/2/files/download", + &no_path + ), + ScopeVerdict::Indeterminate + ); + } + + #[test] + fn dropbox_truncated_and_unparseable_body_fail_closed() { + let scope = json!({ "folders": ["/proj"] }); + let truncated = MatchInput { + body: None, + body_truncated: true, + headers: None, + }; + assert_eq!( + evaluate_scope( + "dropbox", + "api.dropboxapi.com", + Some(&scope), + "/2/files/list_folder", + &truncated + ), + ScopeVerdict::Indeterminate + ); + assert_eq!( + dbx_rpc(b"not json", &["/proj"]), + ScopeVerdict::Indeterminate + ); + // Absent (unbuffered) body while scoped → fail closed. + let absent = MatchInput::empty(); + assert_eq!( + evaluate_scope( + "dropbox", + "api.dropboxapi.com", + Some(&scope), + "/2/files/list_folder", + &absent + ), + ScopeVerdict::Indeterminate + ); + } + + /// Evaluate a Dropbox RPC body against `/proj` at an arbitrary endpoint path. + fn dbx_rpc_at(path: &str, body: &[u8]) -> ScopeVerdict { + let scope = json!({ "folders": ["/proj"] }); + evaluate_scope( + "dropbox", + "api.dropboxapi.com", + Some(&scope), + path, + &body_input(body), + ) + } + + #[test] + fn dropbox_account_endpoint_is_in_scope() { + // `/2/users/get_current_account` sends a `null` body — no folder. + assert_eq!( + dbx_rpc_at("/2/users/get_current_account", b"null"), + ScopeVerdict::InScope + ); + // A no-arg (empty) body on an account endpoint is likewise in scope. + assert_eq!( + dbx_rpc_at("/2/users/get_current_account", b""), + ScopeVerdict::InScope + ); + assert_eq!( + dbx_rpc_at("/2/users/get_space_usage", b"null"), + ScopeVerdict::InScope + ); + // Cursor-pagination `*/continue` inherits the original listing's scope. + assert_eq!( + dbx_rpc_at("/2/files/list_folder/continue", br#"{"cursor":"x"}"#), + ScopeVerdict::InScope + ); + } + + #[test] + fn dropbox_path_less_body_on_a_resource_rpc_fails_closed() { + // A path-less body (or an empty/null body) on any endpoint NOT on the + // non-resource allowlist may address a resource through a field we do + // not parse, so it is fail-closed rather than allowed. + assert_eq!( + dbx_rpc_at("/2/files/list_folder", b"null"), + ScopeVerdict::Indeterminate + ); + assert_eq!( + dbx_rpc_at("/2/files/list_folder", b""), + ScopeVerdict::Indeterminate + ); + // Addressed by shared_folder_id — unconfinable at this layer → deny. + assert_eq!( + dbx_rpc_at( + "/2/sharing/list_folder_members", + br#"{"shared_folder_id":"123"}"# + ), + ScopeVerdict::Indeterminate + ); + // A nested `options.path` we don't parse must not slip through. + assert_eq!( + dbx_rpc_at( + "/2/files/search_v2", + br#"{"query":"x","options":{"path":"/secret"}}"# + ), + ScopeVerdict::Indeterminate + ); + } + + #[test] + fn dropbox_root_path_escapes_a_folder_scope() { + // list_folder on the whole Dropbox ("") is broader than any folder. + assert_eq!( + dbx_rpc(br#"{"path":""}"#, &["/proj"]), + ScopeVerdict::OutOfScope + ); + } + + // ── No scope + unknown provider / shape mismatch ───────────────────── + + #[test] + fn no_scope_is_always_in_scope() { + for provider in ["github-app", "dropbox", "slack"] { + assert_eq!( + evaluate_scope( + provider, + "api.example.com", + None, + "/anything", + &MatchInput::empty() + ), + ScopeVerdict::InScope + ); + // An empty object is "all" → still a no-op. + assert_eq!( + evaluate_scope( + provider, + "api.example.com", + Some(&json!({})), + "/anything", + &MatchInput::empty() + ), + ScopeVerdict::InScope + ); + } + } + + #[test] + fn unknown_provider_with_a_scope_is_indeterminate() { + let scope = json!({ "repositories": ["acme/app"] }); + assert_eq!( + evaluate_scope( + "slack", + "slack.com", + Some(&scope), + "/api/x", + &MatchInput::empty() + ), + ScopeVerdict::Indeterminate + ); + } + + #[test] + fn shape_mismatch_is_indeterminate() { + // GitHub provider carrying a folders scope, or vice versa. + let folders = json!({ "folders": ["/x"] }); + assert_eq!( + evaluate_scope( + "github-app", + "api.github.com", + Some(&folders), + "/repos/a/b", + &MatchInput::empty() + ), + ScopeVerdict::Indeterminate + ); + let repos = json!({ "repositories": ["a/b"] }); + assert_eq!( + evaluate_scope( + "dropbox", + "api.dropboxapi.com", + Some(&repos), + "/2/files/list_folder", + &body_input(b"{}") + ), + ScopeVerdict::Indeterminate + ); + } + + #[test] + fn malformed_scope_is_indeterminate() { + let malformed = json!({ "unknownKey": ["x"] }); + assert_eq!( + evaluate_scope( + "github-app", + "api.github.com", + Some(&malformed), + "/repos/a/b", + &MatchInput::empty() + ), + ScopeVerdict::Indeterminate + ); + } + + // ── needs_body ─────────────────────────────────────────────────────── + + #[test] + fn needs_body_only_for_dropbox_rpc_folders() { + let folders = json!({ "folders": ["/x"] }); + let repos = json!({ "repositories": ["a/b"] }); + assert!(needs_body("dropbox", "api.dropboxapi.com", Some(&folders))); + assert!(needs_body( + "dropbox", + "api.dropboxapi.com:443", + Some(&folders) + )); + // Content host reads the header, never the body. + assert!(!needs_body( + "dropbox", + "content.dropboxapi.com", + Some(&folders) + )); + // GitHub is URL-only. + assert!(!needs_body("github-app", "api.github.com", Some(&repos))); + // No scope → no buffering. + assert!(!needs_body("dropbox", "api.dropboxapi.com", None)); + assert!(!needs_body( + "dropbox", + "api.dropboxapi.com", + Some(&json!({})) + )); + } + + // ── apply_resource_scope (the tightening gate) ─────────────────────── + + fn out_of_scope_repo() -> Value { + json!({ "repositories": ["acme/app"] }) + } + + #[test] + fn gate_blocks_an_out_of_scope_allow() { + let scope = out_of_scope_repo(); + let (decision, blocked) = apply_resource_scope( + PolicyDecision::Allow, + "github-app", + "api.github.com", + Some(&scope), + "/repos/acme/secret/pulls", + &MatchInput::empty(), + ); + assert!(matches!(decision, PolicyDecision::Blocked { .. })); + assert!(blocked); + } + + #[test] + fn gate_leaves_an_in_scope_allow_untouched() { + let scope = out_of_scope_repo(); + let (decision, blocked) = apply_resource_scope( + PolicyDecision::Allow, + "github-app", + "api.github.com", + Some(&scope), + "/repos/acme/app/pulls", + &MatchInput::empty(), + ); + assert!(matches!(decision, PolicyDecision::Allow)); + assert!(!blocked); + } + + #[test] + fn gate_returns_an_existing_block_untouched() { + // Already denied: never re-attributed, never a scope block. + let (decision, blocked) = apply_resource_scope( + PolicyDecision::Blocked { + rule_name: "some rule".to_string(), + }, + "github-app", + "api.github.com", + Some(&out_of_scope_repo()), + "/repos/acme/secret/pulls", + &MatchInput::empty(), + ); + match decision { + PolicyDecision::Blocked { rule_name } => assert_eq!(rule_name, "some rule"), + other => panic!("expected the original block, got {other:?}"), + } + assert!(!blocked); + // Default-policy blocks are equally untouched. + let (decision, blocked) = apply_resource_scope( + PolicyDecision::BlockedByDefaultPolicy, + "github-app", + "api.github.com", + Some(&out_of_scope_repo()), + "/repos/acme/secret/pulls", + &MatchInput::empty(), + ); + assert!(matches!(decision, PolicyDecision::BlockedByDefaultPolicy)); + assert!(!blocked); + } + + #[test] + fn gate_tightens_manual_approval_and_rate_limit_out_of_scope() { + // The tightening beats an approval / rate-limit modifier (stricter-wins). + let scope = out_of_scope_repo(); + let (decision, blocked) = apply_resource_scope( + PolicyDecision::ManualApproval { + rule_id: "r".to_string(), + }, + "github-app", + "api.github.com", + Some(&scope), + "/repos/acme/secret/pulls", + &MatchInput::empty(), + ); + assert!(matches!(decision, PolicyDecision::Blocked { .. })); + assert!(blocked); + + let (decision, blocked) = apply_resource_scope( + PolicyDecision::RateLimited { + rule_name: "r".to_string(), + limit: 1, + window: "minute", + retry_after_secs: 1, + }, + "github-app", + "api.github.com", + Some(&scope), + "/repos/acme/secret/pulls", + &MatchInput::empty(), + ); + assert!(matches!(decision, PolicyDecision::Blocked { .. })); + assert!(blocked); + } + + #[test] + fn gate_is_a_noop_when_no_scope_is_set() { + // The common case: an approval verdict with no scope passes through + // unchanged so the approval flow still runs. + let (decision, blocked) = apply_resource_scope( + PolicyDecision::ManualApproval { + rule_id: "r".to_string(), + }, + "github-app", + "api.github.com", + None, + "/repos/acme/secret/pulls", + &MatchInput::empty(), + ); + assert!(matches!(decision, PolicyDecision::ManualApproval { .. })); + assert!(!blocked); + } + + #[test] + fn gate_indeterminate_out_of_scope_provider_blocks_an_allow() { + // A scope for a provider this build cannot extract must never pass. + let scope = out_of_scope_repo(); + let (decision, blocked) = apply_resource_scope( + PolicyDecision::Allow, + "slack", + "slack.com", + Some(&scope), + "/api/chat.postMessage", + &MatchInput::empty(), + ); + assert!(matches!(decision, PolicyDecision::Blocked { .. })); + assert!(blocked); + } +} diff --git a/apps/gateway/src/policy_engine/types.rs b/apps/gateway/src/policy_engine/types.rs index 56f32ba5..846a9f7f 100644 --- a/apps/gateway/src/policy_engine/types.rs +++ b/apps/gateway/src/policy_engine/types.rs @@ -1,7 +1,9 @@ -//! Shapes for the OSS project-level policy core: the decoded rule, the request -//! context, and the evaluation outcome. Project scope only — OSS has no org -//! layer, no directory identities, and no granular conditions; those live in -//! the EE engine this module replaces under `edition_oss`. +//! Shapes for the OSS policy core: the decoded rule, the request context, and +//! the evaluation outcome. Org + project scopes with agent and directory +//! (user/group) identities — granular conditions stay vacuous here; those live +//! in the EE engine this module replaces under `edition_oss`. There is no +//! agent-group concept: it was deleted, so no identity kind, principal column, +//! or loader references one. /// The rule verdict: the v2 binary. Approval and rate limits are modifiers on /// `Allow` (see `Rule`). @@ -29,14 +31,34 @@ impl RateWindow { } } -/// A rule identity. OSS rules target a specific agent or all agents (empty -/// identity list = "any"). `Other` covers every non-agent identity row a -/// permissive API client might have stored (user/group are OneCLI Cloud -/// capabilities) — it NEVER matches, so such a row narrows to nothing -/// instead of silently widening to "any" (fail-closed). +/// Which scope a decoded rule came from. Drives `MatchedRule.scope` (telemetry +/// attribution) and the org-first tie-break in the two-level evaluator. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum RuleScope { + Organization, + Project, +} + +impl RuleScope { + pub(super) fn as_str(self) -> &'static str { + match self { + RuleScope::Organization => "organization", + RuleScope::Project => "project", + } + } +} + +/// A rule identity (empty identity list = "any"). `Agent` matches the acting +/// agent by id; the directory kinds (`User`/`Group`) match against the +/// connection's resolved `PrincipalSet`. `Other` covers a row naming NO +/// principal the OSS engine understands (malformed, or a future kind) — it +/// NEVER matches, so such a row narrows to nothing instead of silently +/// widening to "any" (fail-closed). #[derive(Debug, Clone)] pub(super) enum Identity { Agent(String), + User(String), + Group(String), Other, } @@ -73,11 +95,12 @@ pub(super) enum Target { Unresolved, } -/// A decoded project rule the evaluator walks. No `scope` field — everything -/// here is project scope (`MatchedRule.scope` is the constant "project"). +/// A decoded rule the evaluator walks, tagged with the scope it came from. #[derive(Debug, Clone)] pub(super) struct Rule { pub id: String, + /// The level (org guardrail vs project) this rule decides for. + pub scope: RuleScope, /// Generation-stable identity — the shared rate counter keys on it, so the /// count survives republishes. pub logical_id: String, @@ -90,9 +113,10 @@ pub(super) struct Rule { pub require_approval: bool, pub rate_limit: Option, pub rate_limit_window: Option, - /// Carried for structural fidelity and routed through the edition-swapped - /// `condition_match` — which is the no-op arm in OSS, so conditions are - /// never evaluated here (matching the legacy OSS gateway exactly). + /// The rule's behavioral conditions (body/header), routed through the + /// edition-swapped `condition_match`. In OSS (Tier 3a) they are EVALUATED + /// byte-level over the buffered body and request headers, carrying the + /// rule's Block-ness so an unevaluable condition fails closed by action. pub conditions: Option, } @@ -115,14 +139,14 @@ pub(super) struct Request { impl Request { /// The deny-default carve: only credentialed, non-LLM traffic can be - /// blocked by the Default Rule. Mirrors `forward.rs`'s `enforce_deny`. + /// blocked by a Default Rule. Mirrors `forward.rs`'s `enforce_deny`. pub(super) fn enforce_deny(&self) -> bool { self.has_injections && !self.is_llm_host } } -/// The winning outcome of an evaluation: an explicit matching rule, the -/// project Default Rule's enforced Block (carrying THAT rule, so telemetry can +/// The winning outcome of an evaluation: an explicit matching rule, a level's +/// Default Rule's enforced Block (carrying THAT rule, so telemetry can /// attribute it — always concrete, never anonymous), or a plain allow. pub(super) enum Outcome<'a> { Rule(&'a Rule), diff --git a/apps/gateway/src/telemetry.rs b/apps/gateway/src/telemetry.rs index 1daabcb2..9b70d373 100644 --- a/apps/gateway/src/telemetry.rs +++ b/apps/gateway/src/telemetry.rs @@ -23,13 +23,36 @@ pub(crate) use crate::telemetry_core::{on_request, RequestEvent}; /// Initialize the telemetry background flush task. /// Must be called once at startup from `main()`. -pub(crate) fn init(pool: PgPool, _cache: Arc) { +pub(crate) fn init(pool: PgPool, cache: Arc) { let (tx, rx) = mpsc::channel::(CHANNEL_CAPACITY); SENDER.set(tx).ok(); - crate::telemetry_core::spawn_flush_loop(flush_loop(rx, pool)); + crate::telemetry_core::spawn_flush_loop(flush_loop(rx, pool, cache)); info!("telemetry initialized (postgres)"); } +/// The identity of a spend counter: `(secret_id, organization_id, period_key)`. +type SpendKey = (String, String, String); + +/// Record a metered spend delta for one counter: accumulate the durable +/// `BudgetSpend` floor and seed the hot counter to the new coherent total. Runs +/// off the request path (in the flush loop), so no request-path latency. Failures +/// are logged, never fatal (fail-open on spend). +async fn record_spend(pool: &PgPool, cache: &dyn CacheStore, key: &SpendKey, delta: i64) { + let (secret_id, organization_id, period_key) = key; + match crate::db::upsert_budget_spend(pool, secret_id, organization_id, period_key, delta).await + { + Ok(total) => { + let counter = crate::budget::counter_key(secret_id, organization_id, period_key); + cache + .set_raw(&counter, &total.to_string(), crate::budget::PERIOD_TTL) + .await; + } + Err(e) => { + warn!(error = %e, secret_id = %secret_id, "budget: failed to record spend"); + } + } +} + async fn insert_batch(pool: &PgPool, events: &[RequestEvent]) -> Result<(), sqlx::Error> { let filtered: Vec<&RequestEvent> = events .iter() @@ -114,7 +137,11 @@ async fn update_batch(pool: &PgPool, events: &[RequestEvent]) { } } -async fn flush_loop(mut rx: mpsc::Receiver, pool: PgPool) { +async fn flush_loop( + mut rx: mpsc::Receiver, + pool: PgPool, + cache: Arc, +) { let mut buffer: Vec = Vec::with_capacity(FLUSH_BATCH_SIZE); loop { @@ -126,9 +153,27 @@ async fn flush_loop(mut rx: mpsc::Receiver, pool: PgPool) { continue; } + // Sum metered spend per counter across the whole drained batch, so a + // busy flush does one upsert per distinct (secret, org, period) rather + // than one per request event. Durability boundary: a charge is volatile + // in the in-memory channel until this runs — a crash before the upsert + // loses at most the unflushed tail (≤FLUSH_INTERVAL_SECS), a fail-open + // under-count consistent with the async-telemetry design; flushed spend + // survives restart (rehydrated from the durable `BudgetSpend` floor). + let mut charges: std::collections::HashMap = + std::collections::HashMap::new(); let mut updates = Vec::new(); let mut regular = Vec::new(); for event in buffer.drain(..) { + if let Some(charge) = event.budget_charge.as_ref() { + *charges + .entry(( + charge.secret_id.clone(), + charge.organization_id.clone(), + charge.period_key.clone(), + )) + .or_default() += charge.cost_nanos; + } if event.existing_log_id.is_some() { updates.push(event); } else { @@ -136,6 +181,11 @@ async fn flush_loop(mut rx: mpsc::Receiver, pool: PgPool) { } } + // Persist the aggregated spend deltas (few keys per flush in practice). + for (key, delta) in &charges { + record_spend(&pool, cache.as_ref(), key, *delta).await; + } + if let Err(e) = insert_batch(&pool, ®ular).await { warn!(count = regular.len(), error = %e, "telemetry batch insert failed"); } diff --git a/apps/web/src/app/(connect)/app-connect/_components/connect-flow.tsx b/apps/web/src/app/(connect)/app-connect/_components/connect-flow.tsx index 6871d9ea..80af71c5 100644 --- a/apps/web/src/app/(connect)/app-connect/_components/connect-flow.tsx +++ b/apps/web/src/app/(connect)/app-connect/_components/connect-flow.tsx @@ -4,7 +4,6 @@ import type { ReactNode } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { Loader2 } from "lucide-react"; import { Button } from "@onecli/ui/components/button"; -import { IS_CLOUD } from "@/lib/env"; import { API_ORIGIN, getAuthToken, getProjectId } from "@/lib/api-fetch"; import { ConnectLayout } from "./connect-layout"; import { ConnectSuccess } from "./connect-success"; @@ -280,28 +279,6 @@ export const ConnectFlow = ({ Use an API key instead )} - {!IS_CLOUD && ( - <> -
-
- - or - -
-
-

- Skip setup with{" "} - - OneCLI Cloud - -

- - )}
); diff --git a/apps/web/src/app/(dashboard)/_components/get-started-dialog.tsx b/apps/web/src/app/(dashboard)/_components/get-started-dialog.tsx index e72a9911..307c0149 100644 --- a/apps/web/src/app/(dashboard)/_components/get-started-dialog.tsx +++ b/apps/web/src/app/(dashboard)/_components/get-started-dialog.tsx @@ -166,17 +166,16 @@ export const GetStartedDialog = ({

- Requires the OneCLI CLI. One-command install is - available with{" "} + Requires the OneCLI CLI — see the{" "} - OneCLI Cloud - - . + quickstart + {" "} + to install it.

)} @@ -215,17 +214,8 @@ export const GetStartedDialog = ({ ) : (
-

- Migration is available with{" "} - - OneCLI Cloud - - . +

+ Automated migration isn't available in this build.

)} diff --git a/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budget-row-actions.tsx b/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budget-row-actions.tsx new file mode 100644 index 00000000..be423abb --- /dev/null +++ b/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budget-row-actions.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useState } from "react"; +import { Loader2, MoreHorizontal } from "lucide-react"; +import { Button } from "@onecli/ui/components/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@onecli/ui/components/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@onecli/ui/components/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@onecli/ui/components/alert-dialog"; +import { Input } from "@onecli/ui/components/input"; +import { Label } from "@onecli/ui/components/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@onecli/ui/components/select"; +import type { Budget } from "@/lib/api"; +import { useDeleteBudget, useUpdateBudget } from "@/hooks/use-budgets"; + +export interface BudgetRowActionsProps { + budget: Budget; +} + +export const BudgetRowActions = ({ budget }: BudgetRowActionsProps) => { + const [editing, setEditing] = useState(false); + const [confirming, setConfirming] = useState(false); + const [amount, setAmount] = useState((budget.limitCents / 100).toFixed(2)); + const [period, setPeriod] = useState<"monthly" | "total">(budget.period); + + const update = useUpdateBudget(); + const remove = useDeleteBudget(); + + const cents = Math.round(Number(amount) * 100); + const canSave = Number.isFinite(cents) && cents > 0 && !update.isPending; + + const onSave = () => { + if (!canSave) return; + update.mutate( + { id: budget.id, input: { limitCents: cents, period } }, + { onSuccess: () => setEditing(false) }, + ); + }; + + return ( + <> + + + + + + setEditing(true)}> + Edit + + setConfirming(true)} + > + Delete + + + + + + + + Edit budget · {budget.secretName} + +
+
+ + setAmount(e.target.value)} + /> +
+
+ + +
+
+ + + + +
+
+ + + + + Remove this budget? + + Spend on {budget.secretName} will no longer be + capped. Recorded spend is kept. + + + + + Cancel + + { + e.preventDefault(); + remove.mutate(budget.id, { + onSuccess: () => setConfirming(false), + }); + }} + disabled={remove.isPending} + > + {remove.isPending ? ( + <> + + Removing… + + ) : ( + "Remove" + )} + + + + + + ); +}; diff --git a/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budget-usage-bar.tsx b/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budget-usage-bar.tsx new file mode 100644 index 00000000..55dfac7b --- /dev/null +++ b/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budget-usage-bar.tsx @@ -0,0 +1,60 @@ +import { cn } from "@onecli/ui/lib/utils"; + +const formatDollars = (cents: number) => `$${(cents / 100).toFixed(2)}`; + +export interface BudgetUsageBarProps { + spentCents: number; + limitCents: number; + period: "monthly" | "total"; +} + +/** + * Spend meter: `$X of $Y this month`, filling green → amber (≥80%) → red + * (≥100%). Accessible in light and dark; the track/fill carry a text label so + * the state does not rely on color alone. + */ +export const BudgetUsageBar = ({ + spentCents, + limitCents, + period, +}: BudgetUsageBarProps) => { + const ratio = limitCents > 0 ? spentCents / limitCents : 0; + const pct = Math.min(Math.max(ratio, 0), 1) * 100; + const over = spentCents >= limitCents && limitCents > 0; + const warn = ratio >= 0.8; + + const fill = over + ? "bg-red-500 dark:bg-red-500" + : warn + ? "bg-amber-500 dark:bg-amber-400" + : "bg-emerald-500 dark:bg-emerald-400"; + + return ( +
+
+
+
+

+ {formatDollars(spentCents)} of {formatDollars(limitCents)}{" "} + {period === "monthly" ? "this month" : "total"} + {over && " — cap reached"} +

+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budgets-content.tsx b/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budgets-content.tsx new file mode 100644 index 00000000..71bb66c5 --- /dev/null +++ b/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budgets-content.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { ApiError } from "@/lib/api"; +import { useBudgets } from "@/hooks/use-budgets"; +import { BudgetsList } from "./budgets-list"; +import { CreateBudgetDialog } from "./create-budget-dialog"; + +export const BudgetsContent = () => { + const { data: budgets = [], isLoading, error } = useBudgets(); + + // Budgets are org guardrails: only admins with an org credential may manage + // them. A 403 is expected for members — render an admin-only notice. + if (error instanceof ApiError && error.status === 403) { + return ( +

+ Spend budgets are managed by organization admins. +

+ ); + } + + // Any other failure (500, network) is a real error — surface it rather than + // falling through to the empty `budgets = []` state, which would present a + // transport failure as "No budgets yet." + if (error) { + return ( +

+ Failed to load spend budgets. Please try again. +

+ ); + } + + return ( +
+
+
+

Spend budgets

+

+ Cap LLM spend per secret. The gateway meters token usage and blocks + new requests once a cap is reached. +

+
+ +
+ + {isLoading ? ( +

Loading budgets…

+ ) : budgets.length === 0 ? ( +

+ No budgets yet. Create one to cap spend on an Anthropic or OpenAI + secret. +

+ ) : ( + + )} +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budgets-list.tsx b/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budgets-list.tsx new file mode 100644 index 00000000..a1cfa645 --- /dev/null +++ b/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/budgets-list.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@onecli/ui/components/table"; +import { Badge } from "@onecli/ui/components/badge"; +import type { Budget } from "@/lib/api"; +import { BudgetUsageBar } from "./budget-usage-bar"; +import { BudgetRowActions } from "./budget-row-actions"; + +export interface BudgetsListProps { + budgets: Budget[]; +} + +export const BudgetsList = ({ budgets }: BudgetsListProps) => { + return ( +
+ + + + Secret + Provider + Period + Usage + + + + + {budgets.map((b) => ( + + {b.secretName} + + {b.secretType} + + + {b.period} + + + + + + + + + ))} + +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/create-budget-dialog.tsx b/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/create-budget-dialog.tsx new file mode 100644 index 00000000..69060c8b --- /dev/null +++ b/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/_components/create-budget-dialog.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Loader2, Plus } from "lucide-react"; +import { Button } from "@onecli/ui/components/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@onecli/ui/components/dialog"; +import { Input } from "@onecli/ui/components/input"; +import { Label } from "@onecli/ui/components/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@onecli/ui/components/select"; +import type { Budget, Secret } from "@/lib/api"; +import { useCreateBudget, useMeteredSecrets } from "@/hooks/use-budgets"; + +export interface CreateBudgetDialogProps { + /** Existing budgets — a secret already capped is excluded from the picker. */ + existing: Budget[]; +} + +const dollarsToCents = (value: string): number | null => { + const dollars = Number(value); + if (!Number.isFinite(dollars) || dollars <= 0) return null; + const cents = Math.round(dollars * 100); + return cents > 0 ? cents : null; +}; + +export const CreateBudgetDialog = ({ existing }: CreateBudgetDialogProps) => { + const [open, setOpen] = useState(false); + const [secretId, setSecretId] = useState(""); + const [amount, setAmount] = useState(""); + const [period, setPeriod] = useState<"monthly" | "total">("monthly"); + + const { data: secrets = [], isLoading: secretsLoading } = + useMeteredSecrets(open); + const create = useCreateBudget(); + + const cappedIds = useMemo( + () => new Set(existing.map((b) => b.secretId)), + [existing], + ); + const available = useMemo( + () => secrets.filter((s) => !cappedIds.has(s.id)), + [secrets, cappedIds], + ); + + const cents = dollarsToCents(amount); + const canSubmit = secretId !== "" && cents !== null && !create.isPending; + + const reset = () => { + setSecretId(""); + setAmount(""); + setPeriod("monthly"); + }; + + const onSubmit = () => { + if (!canSubmit || cents === null) return; + create.mutate( + { secretId, limitCents: cents, period }, + { + onSuccess: () => { + setOpen(false); + reset(); + }, + }, + ); + }; + + return ( + { + setOpen(next); + if (!next) reset(); + }} + > + + + + + + New spend budget + + Cap spend on an Anthropic or OpenAI secret. The gateway blocks new + requests once the cap is reached — one in-flight request may + overshoot. Streaming spend may under-count for some providers. + + + +
+
+ + +
+ +
+ + setAmount(e.target.value)} + /> +
+ +
+ + +
+
+ + + + + +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/page.tsx b/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/page.tsx new file mode 100644 index 00000000..7d344925 --- /dev/null +++ b/apps/web/src/app/(dashboard)/connections/(tabs)/budgets/page.tsx @@ -0,0 +1,10 @@ +import { Suspense } from "react"; +import { BudgetsContent } from "./_components/budgets-content"; + +export default function ConnectionsBudgetsPage() { + return ( + + + + ); +} diff --git a/apps/web/src/app/(dashboard)/connections/_components/app-config-form.tsx b/apps/web/src/app/(dashboard)/connections/_components/app-config-form.tsx index 667b56f7..4938b938 100644 --- a/apps/web/src/app/(dashboard)/connections/_components/app-config-form.tsx +++ b/apps/web/src/app/(dashboard)/connections/_components/app-config-form.tsx @@ -41,7 +41,6 @@ import { useDeleteAppConfig, useToggleAppConfig, } from "@/hooks/use-app-config"; -import { IS_CLOUD } from "@/lib/env"; import { RedirectUri } from "./redirect-uri"; export interface AppConfigFormHandle { @@ -301,23 +300,6 @@ export const AppConfigForm = ({ ? "Override platform defaults with your own." : (hint ?? `Required to connect ${appName}.`)}

- {!hasEnvDefaults && - !hasCredentials && - !enabled && - !IS_CLOUD && ( -

- Or connect instantly with{" "} - - OneCLI Cloud - {" "} - - no credentials needed. -

- )}
diff --git a/apps/web/src/app/(dashboard)/connections/_components/apps-tab.tsx b/apps/web/src/app/(dashboard)/connections/_components/apps-tab.tsx index 7db4b7c3..9f2ee603 100644 --- a/apps/web/src/app/(dashboard)/connections/_components/apps-tab.tsx +++ b/apps/web/src/app/(dashboard)/connections/_components/apps-tab.tsx @@ -25,7 +25,7 @@ import { type AppCategory, } from "./app-categories"; import type { AppDefinition } from "@onecli/api/apps/types"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; import type { PageScope } from "@/lib/api"; import { queryKeys } from "@/lib/api/keys"; import { useConnections } from "@/hooks/use-connections"; @@ -40,8 +40,8 @@ import { useAppMessages, type AppConnectedEvent, } from "@/hooks/use-app-connected"; -import { getCurrentPlan } from "@/lib/user-plan"; import { ProAppDialog } from "@/lib/components/pro-app-dialog"; +import { UnavailableBadge } from "@/lib/components/unavailable-badge"; import { AppIcon } from "./app-icon"; import { ConnectAppDialog } from "./connect-app-dialog"; import { ConfigureCredentialsDialog } from "./configure-credentials-dialog"; @@ -121,10 +121,6 @@ export const AppsTab = ({ const configuredQuery = useConfiguredProviders(pageScope); const envDefaultsQuery = useEnvDefaultProviders(); const availableQuery = useAvailableApps(pageScope); - const planQuery = useQuery({ - queryKey: queryKeys.userPlan.all(), - queryFn: getCurrentPlan, - }); const connectionCounts = useMemo(() => { const counts = new Map(); @@ -143,12 +139,10 @@ export const AppsTab = ({ () => new Set(envDefaultsQuery.data ?? []), [envDefaultsQuery.data], ); - const plan = planQuery.data ?? null; const loading = connectionsQuery.isPending || configuredQuery.isPending || - envDefaultsQuery.isPending || - planQuery.isPending; + envDefaultsQuery.isPending; const handleConnected = useCallback( ({ provider, connectionId }: AppConnectedEvent) => { @@ -387,10 +381,7 @@ export const AppsTab = ({ ) : ( filteredApps.map((app) => { const count = connectionCounts.get(app.id) ?? 0; - const isLocked = - !app.available || - (app.teamOnly === true && - !["team", "scale", "enterprise"].includes(plan ?? "")); + const isLocked = !app.available; return ( {cloudOnly ? ( - - - - - - - Team - - + ) : (
{!hideDetails && ( diff --git a/apps/web/src/app/(dashboard)/connections/_components/configure-credentials-dialog.tsx b/apps/web/src/app/(dashboard)/connections/_components/configure-credentials-dialog.tsx index 9aed0cef..f910a6e1 100644 --- a/apps/web/src/app/(dashboard)/connections/_components/configure-credentials-dialog.tsx +++ b/apps/web/src/app/(dashboard)/connections/_components/configure-credentials-dialog.tsx @@ -15,7 +15,6 @@ import { SecretInput } from "@/components/secret-input"; import type { PageScope } from "@/lib/api"; import { useSaveAppConfig } from "@/hooks/use-app-config"; import type { OAuthConfigField } from "@onecli/api/apps/types"; -import { IS_CLOUD } from "@/lib/env"; import { AppIcon } from "./app-icon"; import { RedirectUri } from "./redirect-uri"; @@ -133,21 +132,6 @@ export const ConfigureCredentialsDialog = ({ > {saving ? "Saving..." : "Save & Connect"} - - {!IS_CLOUD && ( -

- Or use{" "} - - OneCLI Cloud - {" "} - for pre-configured connections. -

- )}
diff --git a/apps/web/src/app/(dashboard)/connections/_components/connections-tabs.tsx b/apps/web/src/app/(dashboard)/connections/_components/connections-tabs.tsx index c7053687..a9f8df40 100644 --- a/apps/web/src/app/(dashboard)/connections/_components/connections-tabs.tsx +++ b/apps/web/src/app/(dashboard)/connections/_components/connections-tabs.tsx @@ -23,6 +23,7 @@ const getTabRoutes = (pathname: string): Record => { apps: base, custom: `${base}/custom`, llms: `${base}/llms`, + budgets: `${base}/budgets`, vaults: `${base}/vaults`, connected: `${base}/connected`, }; @@ -32,6 +33,7 @@ const pathToTab = (pathname: string): string => { const segment = pathname.split("/connections")[1]?.replace(/^\//, "") || ""; if (segment === "custom") return "custom"; if (segment === "llms") return "llms"; + if (segment === "budgets") return "budgets"; if (segment === "vaults") return "vaults"; if (segment === "connected") return "connected"; return "apps"; @@ -61,6 +63,7 @@ export const ConnectionsTabs = ({ apps: basePath, custom: `${basePath}/custom`, llms: `${basePath}/llms`, + budgets: `${basePath}/budgets`, vaults: `${basePath}/vaults`, connected: `${basePath}/connected`, } @@ -104,6 +107,7 @@ export const ConnectionsTabs = ({ Apps Custom LLMs + Budgets {showVaults && ( Vaults diff --git a/apps/web/src/app/(dashboard)/groups/_components/admin-only-notice.tsx b/apps/web/src/app/(dashboard)/groups/_components/admin-only-notice.tsx new file mode 100644 index 00000000..cda9a616 --- /dev/null +++ b/apps/web/src/app/(dashboard)/groups/_components/admin-only-notice.tsx @@ -0,0 +1,20 @@ +import { Lock } from "lucide-react"; +import { Card } from "@onecli/ui/components/card"; + +/** + * Rendered when the groups query 403s — the API is the authority on who is + * an admin (the /team D-K pattern). A plain card: no retry, no toast (the + * 403 is deterministic). + */ +export const AdminOnlyNotice = () => ( + +
+ +
+

Admins only

+

+ Managing groups requires an organization admin. Ask an admin if you need a + group created or changed. +

+
+); diff --git a/apps/web/src/app/(dashboard)/groups/_components/create-group-dialog.tsx b/apps/web/src/app/(dashboard)/groups/_components/create-group-dialog.tsx new file mode 100644 index 00000000..d8e2f311 --- /dev/null +++ b/apps/web/src/app/(dashboard)/groups/_components/create-group-dialog.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@onecli/ui/components/dialog"; +import { Button } from "@onecli/ui/components/button"; +import { Input } from "@onecli/ui/components/input"; +import { Label } from "@onecli/ui/components/label"; +import { cn } from "@onecli/ui/lib/utils"; +import { useCreateGroup } from "@/hooks/use-groups"; + +export interface CreateGroupDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export const CreateGroupDialog = ({ + open, + onOpenChange, +}: CreateGroupDialogProps) => { + const [name, setName] = useState(""); + const [touched, setTouched] = useState(false); + const createGroup = useCreateGroup(); + + const trimmed = name.trim(); + const nameError = + trimmed.length === 0 + ? "Name is required." + : trimmed.length > 100 + ? "Name must be 100 characters or fewer." + : null; + const showNameError = touched && nameError !== null; + + const handleCreate = () => { + setTouched(true); + if (nameError || createGroup.isPending) return; + createGroup.mutate(trimmed, { onSuccess: () => handleClose(false) }); + }; + + const handleClose = (value: boolean) => { + if (!value) { + setName(""); + setTouched(false); + } + onOpenChange(value); + }; + + return ( + + + + Create group + + Groups organize members for project access and policy rules. + + +
+ + setName(e.target.value)} + onBlur={() => setTouched(true)} + onKeyDown={(e) => { + if (e.key === "Enter") handleCreate(); + }} + autoFocus + className={cn(showNameError && "border-destructive")} + /> + {showNameError && ( +

{nameError}

+ )} +
+ + + + +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/groups/_components/group-members-dialog.tsx b/apps/web/src/app/(dashboard)/groups/_components/group-members-dialog.tsx new file mode 100644 index 00000000..5a5480cb --- /dev/null +++ b/apps/web/src/app/(dashboard)/groups/_components/group-members-dialog.tsx @@ -0,0 +1,277 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { UsersRound, Loader2, Search, TriangleAlert } from "lucide-react"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@onecli/ui/components/dialog"; +import { Button } from "@onecli/ui/components/button"; +import { Input } from "@onecli/ui/components/input"; +import { Badge } from "@onecli/ui/components/badge"; +import { Checkbox } from "@onecli/ui/components/checkbox"; +import { MAX_GROUP_MEMBERS } from "@onecli/api/validations/org"; +import { useOrgMembersList } from "@/hooks/use-org-members"; +import { useGroupMembers, useSetGroupMembers } from "@/hooks/use-groups"; + +export interface GroupMembersDialogProps { + groupId: string; + groupName: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +/** + * Replace-set member picker for one group: candidates are the org's members + * (`useOrgMembersList`), the current set is the group's members, and Save PUTs + * the exact selection back. Scales via a filter + select-all/clear, with a + * viewport-bounded scroll list so the dialog never overflows. + */ +export const GroupMembersDialog = ({ + groupId, + groupName, + open, + onOpenChange, +}: GroupMembersDialogProps) => { + const { + data: candidates = [], + isPending: candidatesPending, + isError: candidatesError, + } = useOrgMembersList(open); + const { + data: current = [], + isPending: currentPending, + isError: currentError, + } = useGroupMembers(groupId, open); + const setMembers = useSetGroupMembers(); + const isPending = candidatesPending || currentPending; + // Either feed failing must surface as an ERROR, never an empty baseline: + // this is a replace-set picker, so seeding from a failed current-members + // read would render every real member unchecked and let one toggle + Save + // silently wipe the group's membership. + const isError = candidatesError || currentError; + + const [selected, setSelected] = useState>(() => new Set()); + const [saving, setSaving] = useState(false); + const [search, setSearch] = useState(""); + + const initialSelected = useMemo( + () => new Set(current.map((m) => m.userId)), + [current], + ); + + // Seed the edit buffer once per open, once both feeds load — guarded so a + // background refetch can't clobber in-progress edits. Search clears on close. + const seededRef = useRef(false); + useEffect(() => { + if (!open) { + seededRef.current = false; + setSearch(""); + return; + } + if (seededRef.current || isPending || isError) return; + setSelected(new Set(initialSelected)); + seededRef.current = true; + }, [open, isPending, isError, initialSelected]); + + const filteredCandidates = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return candidates; + return candidates.filter( + (m) => + m.email.toLowerCase().includes(q) || + (m.name ?? "").toLowerCase().includes(q), + ); + }, [candidates, search]); + + const dirty = useMemo(() => { + if (selected.size !== initialSelected.size) return true; + for (const id of selected) if (!initialSelected.has(id)) return true; + return false; + }, [selected, initialSelected]); + + const toggle = (userId: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(userId)) next.delete(userId); + else next.add(userId); + return next; + }); + }; + + // Select-all/clear act on ALL candidates, not just the filtered view. A + // group caps at MAX_GROUP_MEMBERS server-side, so past that many candidates + // Select-all can't produce a saveable set — disable it and say why rather + // than let Save PUT an oversized set that fails validation with a raw 422. + const selectAllExceedsCap = candidates.length > MAX_GROUP_MEMBERS; + const selectAll = () => setSelected(new Set(candidates.map((m) => m.userId))); + const clearAll = () => setSelected(new Set()); + + const handleSave = async () => { + setSaving(true); + try { + await setMembers.mutateAsync({ groupId, userIds: [...selected] }); + onOpenChange(false); + toast.success("Group members updated"); + } catch { + // The mutation hook already toasts the server reason — just keep the + // dialog open so the selection isn't lost. + } finally { + setSaving(false); + } + }; + + return ( + + + + Members of {groupName} +

+ Choose which organization members belong to this group. Project + access granted to the group follows its membership. +

+
+ +
+ {isError ? ( +
+ +
+

+ Couldn't load members +

+

+ Something went wrong fetching the member lists. Close the + dialog and try again. +

+
+
+ ) : isPending ? ( +
+ +
+ ) : candidates.length === 0 ? ( +
+
+ +
+

No members yet

+

+ Invite teammates from the Team page to add them to groups. +

+
+ ) : ( +
+ {/* Search */} +
+
+ + {/* Toolbar: count + bulk actions */} +
+

+ + {selected.size} + {" "} + of {candidates.length} selected +

+
+ + / + +
+
+ + {/* List — a native max-height scroller: it shrinks to fit a few + members and caps at the viewport, scrolling the rows for many. + (A Radix ScrollArea can't scroll under `max-height` — its + viewport needs a *definite* height — so it would clip instead + of scroll; a plain overflow container is correct here.) */} +
+
+ {filteredCandidates.map((memberRow) => ( + + ))} + + {filteredCandidates.length === 0 && ( +

+ No members match “{search}” +

+ )} +
+
+
+ )} +
+ + + + + +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/groups/_components/group-row-actions.tsx b/apps/web/src/app/(dashboard)/groups/_components/group-row-actions.tsx new file mode 100644 index 00000000..12aa3d3a --- /dev/null +++ b/apps/web/src/app/(dashboard)/groups/_components/group-row-actions.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { useState } from "react"; +import { MoreHorizontal, Loader2 } from "lucide-react"; +import { Button } from "@onecli/ui/components/button"; +import { Input } from "@onecli/ui/components/input"; +import { Label } from "@onecli/ui/components/label"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@onecli/ui/components/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@onecli/ui/components/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@onecli/ui/components/alert-dialog"; +import { useRenameGroup, useDeleteGroup } from "@/hooks/use-groups"; +import type { GroupRow } from "@/lib/api"; +import { GroupMembersDialog } from "./group-members-dialog"; + +export interface GroupRowActionsProps { + group: GroupRow; +} + +export const GroupRowActions = ({ group }: GroupRowActionsProps) => { + const [renameOpen, setRenameOpen] = useState(false); + const [membersOpen, setMembersOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [name, setName] = useState(group.name); + const rename = useRenameGroup(); + const remove = useDeleteGroup(); + // SCIM-sourced rows (possible after an EE-to-OSS migration) are read-only: + // every mutation deterministically 409s server-side, so offering the + // actions would only surface error toasts. + const isManual = group.source === "manual"; + + const trimmed = name.trim(); + const nameError = + trimmed.length === 0 + ? "Name is required." + : trimmed.length > 100 + ? "Name must be 100 characters or fewer." + : null; + + const handleRenameOpen = (open: boolean) => { + if (open) setName(group.name); + setRenameOpen(open); + }; + + const handleRename = () => { + if (nameError || rename.isPending) return; + rename.mutate( + { groupId: group.id, name: trimmed }, + { onSuccess: () => setRenameOpen(false) }, + ); + }; + + const handleDelete = () => { + remove.mutate(group.id, { onSuccess: () => setDeleteOpen(false) }); + }; + + return ( + <> + + + + + + {!isManual && ( + + Managed by your identity provider + + )} + handleRenameOpen(true)} + > + Rename + + setMembersOpen(true)} + > + Manage members + + + setDeleteOpen(true)} + > + Delete + + + + + + + + Rename {group.name} + +
+ + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleRename(); + }} + autoFocus + /> + {nameError && name !== group.name && ( +

{nameError}

+ )} +
+ + + + +
+
+ + + + + + + Delete {group.name}? + + {/* The impact counts matter: the project-access cascade is a + silent access revocation. */} + This removes the group and its {group.memberCount} membership + {group.memberCount === 1 ? "" : "s"}. Any project access granted + through this group is revoked immediately. This cannot be undone. + + + + + Cancel + + { + e.preventDefault(); + handleDelete(); + }} + disabled={remove.isPending} + > + {remove.isPending ? ( + <> + + Deleting... + + ) : ( + "Delete" + )} + + + + + + ); +}; diff --git a/apps/web/src/app/(dashboard)/groups/_components/groups-content.tsx b/apps/web/src/app/(dashboard)/groups/_components/groups-content.tsx new file mode 100644 index 00000000..480cc11e --- /dev/null +++ b/apps/web/src/app/(dashboard)/groups/_components/groups-content.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { Card } from "@onecli/ui/components/card"; +import { Skeleton } from "@onecli/ui/components/skeleton"; +import { useGroups } from "@/hooks/use-groups"; +import { AdminOnlyNotice } from "./admin-only-notice"; +import { LocalModeNotice } from "./local-mode-notice"; +import { GroupsTable } from "./groups-table"; +import { RoleMappingsSection } from "./role-mappings-section"; + +export interface GroupsContentProps { + /** Threaded from the RSC page (server-only auth mode); false = local mode. */ + groupsEnabled: boolean; +} + +export const GroupsContent = ({ groupsEnabled }: GroupsContentProps) => { + // The groups query's 403 is the admin authority (the /team D-K pattern): a + // non-admin gets a deterministic error and the surface renders the + // admin-only notice — the API gates the whole router on admin anyway. + const groups = useGroups(groupsEnabled); + + // Local mode has a single built-in identity, so groups are inert — return + // before the query's pending/error branches so no doomed request fires + // against an unreachable org backend (matches TeamContent's ordering). + if (!groupsEnabled) return ; + + if (groups.isPending) { + return ( +
+ {[1, 2].map((i) => ( + +
+
+ + +
+ +
+
+ ))} +
+ ); + } + + if (groups.isError) return ; + + return ( +
+ + {/* Role mappings live below the groups table: they map these groups to + org roles, so authoring them alongside the groups they reference keeps + the whole group-based access model on one page. */} + +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/groups/_components/groups-table.tsx b/apps/web/src/app/(dashboard)/groups/_components/groups-table.tsx new file mode 100644 index 00000000..c69e3f37 --- /dev/null +++ b/apps/web/src/app/(dashboard)/groups/_components/groups-table.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useState } from "react"; +import { Plus, UsersRound } from "lucide-react"; +import { Button } from "@onecli/ui/components/button"; +import { Badge } from "@onecli/ui/components/badge"; +import { Card } from "@onecli/ui/components/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@onecli/ui/components/table"; +import type { GroupRow } from "@/lib/api"; +import { GroupRowActions } from "./group-row-actions"; +import { CreateGroupDialog } from "./create-group-dialog"; + +export interface GroupsTableProps { + groups: GroupRow[]; +} + +// No error prop: the parent (groups-content) early-returns AdminOnlyNotice on +// the groups query's error, so this table only renders with a live feed. +export const GroupsTable = ({ groups }: GroupsTableProps) => { + const [createOpen, setCreateOpen] = useState(false); + + return ( +
+
+ +
+ {groups.length === 0 ? ( + +
+ +
+

No groups yet

+

+ Create a group to organize members for project access and policy. +

+
+ ) : ( + + + + + Name + Members + Created + + + + + {groups.map((row) => ( + + + {row.name} + {row.source === "scim" && ( + + IdP-managed + + )} + + + {row.memberCount} + + + {new Date(row.createdAt).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} + + + + + + ))} + +
+
+ )} + +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/groups/_components/local-mode-notice.tsx b/apps/web/src/app/(dashboard)/groups/_components/local-mode-notice.tsx new file mode 100644 index 00000000..2dbdd06f --- /dev/null +++ b/apps/web/src/app/(dashboard)/groups/_components/local-mode-notice.tsx @@ -0,0 +1,21 @@ +import { UsersRound } from "lucide-react"; +import { Card } from "@onecli/ui/components/card"; + +/** + * Local auth mode has exactly one identity, so groups are inert — there is + * nobody to group. + */ +export const LocalModeNotice = () => ( + +
+ +
+

Groups are unavailable in local mode

+

+ This instance runs in local auth mode, which has exactly one built-in + identity (admin@localhost) — there is nobody to group. To invite teammates + and group them, configure Google OAuth (NEXTAUTH_SECRET + + GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET) and restart. +

+
+); diff --git a/apps/web/src/app/(dashboard)/groups/_components/role-mapping-dialog.tsx b/apps/web/src/app/(dashboard)/groups/_components/role-mapping-dialog.tsx new file mode 100644 index 00000000..dfbab07b --- /dev/null +++ b/apps/web/src/app/(dashboard)/groups/_components/role-mapping-dialog.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@onecli/ui/components/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@onecli/ui/components/select"; +import { Button } from "@onecli/ui/components/button"; +import { Label } from "@onecli/ui/components/label"; +import { + useCreateRoleMapping, + useUpdateRoleMapping, + useRoleMappingPreview, +} from "@/hooks/use-role-mappings"; +import type { GroupRow, RoleMappingRow } from "@/lib/api"; + +export interface RoleMappingDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Groups selectable for a NEW mapping — the ones without a mapping already + * (a group maps to at most one role). Ignored when editing. */ + availableGroups: GroupRow[]; + /** Present = edit an existing mapping (group is fixed, only the role changes); + * absent = create. */ + mapping?: RoleMappingRow; +} + +type Role = "admin" | "member"; + +/** + * Create or edit a group→role mapping. On create the admin picks a group and a + * role; on edit the group is fixed (a mapping's group is its identity — only + * the granted role is editable) and the select is disabled. + * + * The live preview is the honest part: it asks the server how many members + * WOULD change role under the proposed mapping before it is written, so an + * admin sees the blast radius (a raise-only change, but still a change) up + * front rather than from a surprised teammate. + */ +export const RoleMappingDialog = ({ + open, + onOpenChange, + availableGroups, + mapping, +}: RoleMappingDialogProps) => { + const isEdit = mapping !== undefined; + const [groupId, setGroupId] = useState(mapping?.groupId ?? ""); + const [role, setRole] = useState(mapping?.role ?? "member"); + + const create = useCreateRoleMapping(); + const update = useUpdateRoleMapping(); + const pending = create.isPending || update.isPending; + + // Reset the form whenever the dialog (re)opens — a create after an edit must + // not inherit the edited mapping's group/role. + useEffect(() => { + if (open) { + setGroupId(mapping?.groupId ?? ""); + setRole(mapping?.role ?? "member"); + } + }, [open, mapping]); + + // Only preview once a group is chosen — the hook already no-ops on an empty + // groupId, but this keeps the query key stable. + const previewInput = useMemo( + () => (groupId ? { groupId, role } : null), + [groupId, role], + ); + const preview = useRoleMappingPreview(previewInput); + + const handleSubmit = () => { + if (!groupId || pending) return; + if (isEdit) { + update.mutate( + { id: mapping.id, input: { role } }, + { onSuccess: () => onOpenChange(false) }, + ); + } else { + create.mutate( + { groupId, role }, + { onSuccess: () => onOpenChange(false) }, + ); + } + }; + + const affected = preview.data?.affectedCount ?? 0; + + return ( + + + + + {isEdit ? "Edit role mapping" : "Add role mapping"} + + + Members of the group are granted at least this role. Mappings only + raise a member's role — they never lower it. + + + +
+
+ + +
+ +
+ + +
+ +

+ {!groupId + ? "Select a group to preview the impact." + : preview.isPending + ? "Checking impact…" + : preview.isError + ? "Couldn't preview the impact." + : affected === 0 + ? "No members would change role." + : `${affected} member${affected === 1 ? "" : "s"} would be raised to ${role}.`} +

+
+ + + + + +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/groups/_components/role-mapping-row-actions.tsx b/apps/web/src/app/(dashboard)/groups/_components/role-mapping-row-actions.tsx new file mode 100644 index 00000000..e77b2f82 --- /dev/null +++ b/apps/web/src/app/(dashboard)/groups/_components/role-mapping-row-actions.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useState } from "react"; +import { ArrowDown, ArrowUp, Loader2, MoreHorizontal } from "lucide-react"; +import { Button } from "@onecli/ui/components/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@onecli/ui/components/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@onecli/ui/components/alert-dialog"; +import { useDeleteRoleMapping } from "@/hooks/use-role-mappings"; +import type { GroupRow, RoleMappingRow } from "@/lib/api"; +import { RoleMappingDialog } from "./role-mapping-dialog"; + +export interface RoleMappingRowActionsProps { + mapping: RoleMappingRow; + /** Groups selectable when editing — the edit dialog fixes the group, so this + * only needs to name the current one, but the dialog shares the prop. */ + groups: GroupRow[]; + canMoveUp: boolean; + canMoveDown: boolean; + /** Move this mapping one step higher/lower in priority. Owned by the section + * (it holds the single reorder mutation over the full ordered set). */ + onMove: (direction: "up" | "down") => void; + /** A reorder is in flight for the whole list — lock the move controls. */ + reordering: boolean; +} + +/** + * Per-row controls for a role mapping: reorder (priority is first-match, so the + * order is load-bearing), edit the granted role, and delete. Reordering is + * lifted to the section so a single `reorder` call carries the whole ordered + * id set (a partial order 409s server-side). + */ +export const RoleMappingRowActions = ({ + mapping, + groups, + canMoveUp, + canMoveDown, + onMove, + reordering, +}: RoleMappingRowActionsProps) => { + const [editOpen, setEditOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const remove = useDeleteRoleMapping(); + + return ( +
+ + + + + + + + + setEditOpen(true)}> + Edit role + + + setDeleteOpen(true)} + > + Delete + + + + + + + + + + + Delete the {mapping.groupName} mapping? + + + Members of {mapping.groupName} will no longer be raised to{" "} + {mapping.role} through this mapping. Anyone whose role was granted + only by it reverts to their base role. This cannot be undone. + + + + + Cancel + + { + e.preventDefault(); + remove.mutate(mapping.id, { + onSuccess: () => setDeleteOpen(false), + }); + }} + disabled={remove.isPending} + > + {remove.isPending ? ( + <> + + Deleting... + + ) : ( + "Delete" + )} + + + + +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/groups/_components/role-mappings-section.tsx b/apps/web/src/app/(dashboard)/groups/_components/role-mappings-section.tsx new file mode 100644 index 00000000..3459cc5a --- /dev/null +++ b/apps/web/src/app/(dashboard)/groups/_components/role-mappings-section.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { useState } from "react"; +import { Lock, Plus, Shuffle } from "lucide-react"; +import { Badge } from "@onecli/ui/components/badge"; +import { Button } from "@onecli/ui/components/button"; +import { Card } from "@onecli/ui/components/card"; +import { Skeleton } from "@onecli/ui/components/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@onecli/ui/components/table"; +import { useGroups } from "@/hooks/use-groups"; +import { + useReorderRoleMappings, + useRoleMappings, +} from "@/hooks/use-role-mappings"; +import { RoleMappingDialog } from "./role-mapping-dialog"; +import { RoleMappingRowActions } from "./role-mapping-row-actions"; + +export interface RoleMappingsSectionProps { + /** Threaded from the RSC page — false = local auth mode (no org backend). */ + groupsEnabled: boolean; +} + +/** + * Group→role mappings: members of a mapped group are granted at least its role. + * + * Ordering is load-bearing. Mappings are first-match by priority (top of the + * list wins), and the effect is MONOTONIC — a mapping can only RAISE a member's + * role, never lower it. So a member in several mapped groups lands on the + * highest role any applicable mapping grants, and reordering only matters where + * mappings would otherwise disagree. + * + * The section sits below the groups table on `/groups`. It renders for admins + * only: the parent already replaces the whole surface with the admin-only + * notice when the groups directory 403s, so a non-admin never reaches this. + * The defensive error branch stays for the rare case the two reads disagree. + */ +export const RoleMappingsSection = ({ + groupsEnabled, +}: RoleMappingsSectionProps) => { + const mappings = useRoleMappings(groupsEnabled); + // The create dialog offers groups that DON'T already have a mapping (a group + // maps to at most one role — a second create 409s server-side). + const groups = useGroups(groupsEnabled); + const reorder = useReorderRoleMappings(); + const [createOpen, setCreateOpen] = useState(false); + + // Inert without an org backend — the parent returns before this in local mode, + // but guard anyway so an accidental mount fires no doomed request. + if (!groupsEnabled) return null; + + const rows = mappings.data ?? []; + const mappedGroupIds = new Set(rows.map((m) => m.groupId)); + const availableGroups = (groups.data ?? []).filter( + (g) => !mappedGroupIds.has(g.id), + ); + + const move = (index: number, direction: "up" | "down") => { + const target = direction === "up" ? index - 1 : index + 1; + if (target < 0 || target >= rows.length) return; + const orderedIds = rows.map((m) => m.id); + const moved = orderedIds.splice(index, 1); + orderedIds.splice(target, 0, ...moved); + reorder.mutate(orderedIds); + }; + + return ( +
+
+
+

Role mappings

+

+ Grant members of a group an organization role automatically. + Mappings only raise a + member's role, never lower it. When several apply, the + highest-priority mapping wins — top of the list first. +

+
+ +
+ + {mappings.isPending ? ( + +
+ + +
+
+ ) : mappings.isError ? ( + +
+ +
+

Admins only

+

+ Managing role mappings requires an organization admin. +

+
+ ) : rows.length === 0 ? ( + +
+ +
+

No role mappings yet

+

+ Map a group to a role so its members are granted it automatically. +

+
+ ) : ( + + + + + Priority + Group + Grants role + Members + Order + + + + {rows.map((mapping, index) => ( + + + {index + 1} + + + {mapping.groupName} + + + + {mapping.role} + + + + {mapping.memberCount} + + + 0} + canMoveDown={index < rows.length - 1} + onMove={(direction) => move(index, direction)} + reordering={reorder.isPending} + /> + + + ))} + +
+
+ )} + + +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/groups/loading.tsx b/apps/web/src/app/(dashboard)/groups/loading.tsx new file mode 100644 index 00000000..447a4c2a --- /dev/null +++ b/apps/web/src/app/(dashboard)/groups/loading.tsx @@ -0,0 +1,27 @@ +import { Card } from "@onecli/ui/components/card"; +import { Skeleton } from "@onecli/ui/components/skeleton"; +import { PageHeader } from "@dashboard/page-header"; + +export default function GroupsLoading() { + return ( +
+ +
+ {[1, 2].map((i) => ( + +
+
+ + +
+ +
+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/groups/page.tsx b/apps/web/src/app/(dashboard)/groups/page.tsx new file mode 100644 index 00000000..d463f257 --- /dev/null +++ b/apps/web/src/app/(dashboard)/groups/page.tsx @@ -0,0 +1,30 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; +import { PageHeader } from "@dashboard/page-header"; +import { getAuthMode } from "@/lib/auth/auth-mode"; +import { GroupsContent } from "./_components/groups-content"; + +export const metadata: Metadata = { + title: "Groups", +}; + +export default function GroupsPage() { + // Auth mode is server-only (fs-backed runtime config), so it is resolved + // here and threaded down as a prop (the TeamContent precedent). Local mode + // gates groups entirely — one built-in identity means nobody to group. No + // server-side auth/role resolution at page level — no dashboard page does + // it, and the API's 403 is the authority on who is an admin. + const groupsEnabled = getAuthMode() !== "local"; + + return ( +
+ + + + +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/policy/loading.tsx b/apps/web/src/app/(dashboard)/policy/loading.tsx new file mode 100644 index 00000000..2b7a7a0f --- /dev/null +++ b/apps/web/src/app/(dashboard)/policy/loading.tsx @@ -0,0 +1,30 @@ +import { Card } from "@onecli/ui/components/card"; +import { Skeleton } from "@onecli/ui/components/skeleton"; + +/** + * Route-level skeleton. Mirrors the page frame (heading + rule cards) so the + * layout doesn't jump when the client editor mounts. + */ +export default function PolicyLoading() { + return ( +
+
+ + +
+
+ {[1, 2].map((i) => ( + +
+
+ + +
+ +
+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/policy/page.tsx b/apps/web/src/app/(dashboard)/policy/page.tsx new file mode 100644 index 00000000..f41c687b --- /dev/null +++ b/apps/web/src/app/(dashboard)/policy/page.tsx @@ -0,0 +1,34 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; +import { PageHeader } from "@dashboard/page-header"; +import { PolicyEditor } from "@/lib/policy-editor"; + +export const metadata: Metadata = { + title: "Policy", +}; + +/** + * The ORGANIZATION policy surface — the guardrails every project is evaluated + * against. The gateway evaluates these rules alongside each project's own + * policy and takes the stricter verdict (`policy_engine/evaluate.rs`), so they + * override nothing and can only tighten. + * + * A single scope: project-scope authoring retired in attach-model step 6 + * (`/v1/policy/*` is 410'd; project rules compile from agent grants), so there + * is no scope switcher. No server-side role resolution — the API's 403 is the + * authority on who is an admin, and `PolicyEditor` renders the degrade when the + * org policy read fails. + */ +export default function PolicyPage() { + return ( +
+ + + + +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/settings/project/_components/admin-only-notice.tsx b/apps/web/src/app/(dashboard)/settings/project/_components/admin-only-notice.tsx new file mode 100644 index 00000000..db4e627a --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/project/_components/admin-only-notice.tsx @@ -0,0 +1,22 @@ +import { Lock } from "lucide-react"; + +/** + * Rendered inside the sharing dialog when the candidate directories 403. + * `/v1/org/members` and `/v1/org/groups` are admin-only, so a project owner who + * is not an org admin can still SEE and prune the current bindings — they just + * cannot enumerate who else exists to add. The API is the authority; this is + * what its deterministic 403 looks like. + */ +export const AdminOnlyNotice = () => ( +
+
+ +
+

Admins only

+

+ Browsing the organization's members and groups requires an admin. Ask + an admin to share this project, or remove existing access from the list + behind this dialog. +

+
+); diff --git a/apps/web/src/app/(dashboard)/settings/project/_components/delete-project-card.tsx b/apps/web/src/app/(dashboard)/settings/project/_components/delete-project-card.tsx new file mode 100644 index 00000000..45e5606d --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/project/_components/delete-project-card.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@onecli/ui/components/card"; +import { Button } from "@onecli/ui/components/button"; +import { Input } from "@onecli/ui/components/input"; +import { Label } from "@onecli/ui/components/label"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@onecli/ui/components/alert-dialog"; +import type { Project } from "@/lib/api"; +import { useDeleteProject } from "@/hooks/use-projects"; + +export interface DeleteProjectCardProps { + project: Project; + canManage: boolean; +} + +/** + * `Project.name` is nullable (legacy `accounts` rows carry NULL), and those + * neglected projects are exactly the ones an admin wants gone — so the + * type-to-confirm gate falls back to a fixed literal instead of an empty string + * nobody can type. + */ +const FALLBACK_CONFIRMATION = "delete"; + +export const DeleteProjectCard = ({ + project, + canManage, +}: DeleteProjectCardProps) => { + const [open, setOpen] = useState(false); + const [confirmation, setConfirmation] = useState(""); + const remove = useDeleteProject(); + const router = useRouter(); + + const name = project.name?.trim() ?? ""; + const expected = name || FALLBACK_CONFIRMATION; + // Client-side only: `apiDelete` sends no body, so this is friction, not a + // check. The server's refusals (last project in the org, a member who would + // be left with none) are the real guards and their messages are toasted + // verbatim by the hook. + const confirmed = confirmation.trim() === expected; + + const handleOpenChange = (next: boolean) => { + if (next) setConfirmation(""); + setOpen(next); + }; + + const handleDelete = () => { + if (!confirmed || remove.isPending) return; + remove.mutate(project.id, { + onSuccess: () => { + setOpen(false); + toast.success("Project deleted"); + // The next request re-resolves a different default project. + router.replace("/overview"); + }, + }); + }; + + return ( + <> + + + Delete this project + + Agents, API keys, secrets, connections and policy rules in this + project are deleted permanently. Activity history is kept. This + cannot be undone. + + + + + + + + {/* AlertDialog, not Dialog: the app's convention for every destructive + confirm (group + member row actions, connections, secrets, keys). */} + + + + + Delete {name || "this project"}? + + + This deletes the project's agents, API keys, secrets, app + connections and policy rules. Anyone who relies on this project + loses access to it. Activity history is kept. + + +
+ + setConfirmation(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleDelete(); + }} + /> +
+ + + Cancel + + {/* preventDefault + manual mutate keeps the dialog open while the + request is in flight (the group-row-actions pattern). */} + { + e.preventDefault(); + handleDelete(); + }} + disabled={!confirmed || remove.isPending} + > + {remove.isPending ? ( + <> + + Deleting... + + ) : ( + "Delete project" + )} + + +
+
+ + ); +}; diff --git a/apps/web/src/app/(dashboard)/settings/project/_components/local-mode-notice.tsx b/apps/web/src/app/(dashboard)/settings/project/_components/local-mode-notice.tsx new file mode 100644 index 00000000..0eafda21 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/project/_components/local-mode-notice.tsx @@ -0,0 +1,20 @@ +import { UsersRound } from "lucide-react"; + +/** + * Local auth mode has exactly one built-in identity, so there is nobody to + * share a project WITH. Rename and delete stay live — only this card degrades. + */ +export const LocalModeNotice = () => ( +
+
+ +
+

Sharing is unavailable in local mode

+

+ This instance runs in local auth mode, which has exactly one built-in + identity (admin@localhost). To invite teammates and share projects with + them, configure Google OAuth (NEXTAUTH_SECRET + GOOGLE_CLIENT_ID/ + GOOGLE_CLIENT_SECRET) and restart. +

+
+); diff --git a/apps/web/src/app/(dashboard)/settings/project/_components/project-access-card.tsx b/apps/web/src/app/(dashboard)/settings/project/_components/project-access-card.tsx new file mode 100644 index 00000000..cb1e5473 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/project/_components/project-access-card.tsx @@ -0,0 +1,419 @@ +"use client"; + +import { useState } from "react"; +import { Loader2, Trash2, UserPlus, UsersRound } from "lucide-react"; +import { toast } from "sonner"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@onecli/ui/components/card"; +import { Button } from "@onecli/ui/components/button"; +import { Badge } from "@onecli/ui/components/badge"; +import { Skeleton } from "@onecli/ui/components/skeleton"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@onecli/ui/components/select"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@onecli/ui/components/alert-dialog"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@onecli/ui/components/tooltip"; +import type { ProjectAccessBindings, SetProjectAccessInput } from "@/lib/api"; +import { + useProjectAccess, + useSetProjectAccess, +} from "@/hooks/use-project-access"; +import { LocalModeNotice } from "./local-mode-notice"; +import { ProjectAccessDialog } from "./project-access-dialog"; + +export interface ProjectAccessCardProps { + projectId: string; + userId: string; + canManage: boolean; + isOrgAdmin: boolean; + sharingEnabled: boolean; +} + +/** + * The contract has NO per-row endpoints: every row action PUTs the FULL set. + * So each control builds the next set from the currently-cached bindings and + * applies exactly one change — one write path, with the server's guards (an + * owner must remain; nobody may strand themselves) as the safety net. + */ +const toInput = (bindings: ProjectAccessBindings): SetProjectAccessInput => ({ + users: bindings.users.map((u) => ({ userId: u.userId, role: u.role })), + groupIds: bindings.groups.map((g) => g.groupId), +}); + +/** The row a confirmation dialog is currently asking about. */ +type PendingRemoval = + | { kind: "user"; userId: string; label: string; isSelf: boolean } + | { kind: "group"; groupId: string; name: string; memberCount: number }; + +export const ProjectAccessCard = ({ + projectId, + userId, + canManage, + isOrgAdmin, + sharingEnabled, +}: ProjectAccessCardProps) => { + const [dialogOpen, setDialogOpen] = useState(false); + const [removal, setRemoval] = useState(null); + // Which ROW is mutating, so the click that started it shows a spinner + // instead of silently greying every control out. + const [busyRowId, setBusyRowId] = useState(null); + const access = useProjectAccess(projectId, sharingEnabled); + const setAccess = useSetProjectAccess(); + + const bindings = access.data; + const ownerCount = + bindings?.users.filter((u) => u.role === "owner").length ?? 0; + + const apply = (rowId: string, next: SetProjectAccessInput) => { + setBusyRowId(rowId); + setAccess.mutate( + { projectId, ...next }, + // The hook toasts the server's reason on failure — including the guard + // messages, which are the whole point of this surface. + { + onSuccess: () => { + setRemoval(null); + toast.success("Project access updated"); + }, + onSettled: () => setBusyRowId(null), + }, + ); + }; + + const removeUser = (targetUserId: string) => { + if (!bindings) return; + const next = toInput(bindings); + apply(targetUserId, { + ...next, + users: next.users.filter((u) => u.userId !== targetUserId), + }); + }; + + const changeUserRole = (targetUserId: string, role: "owner" | "member") => { + if (!bindings) return; + const next = toInput(bindings); + apply(targetUserId, { + ...next, + users: next.users.map((u) => + u.userId === targetUserId ? { ...u, role } : u, + ), + }); + }; + + const removeGroup = (groupId: string) => { + if (!bindings) return; + const next = toInput(bindings); + apply(groupId, { + ...next, + groupIds: next.groupIds.filter((id) => id !== groupId), + }); + }; + + const confirmRemoval = () => { + if (!removal) return; + if (removal.kind === "user") removeUser(removal.userId); + else removeGroup(removal.groupId); + }; + + return ( + + + Access + + People and groups who can use this project. Owners can also rename, + share and delete it. + + {sharingEnabled && ( + + + + )} + + + {!sharingEnabled ? ( + + ) : access.isPending ? ( +
+ {[1, 2].map((i) => ( + + ))} +
+ ) : access.isError ? ( +
+

Couldn't load access

+

+ Something went wrong fetching this project's bindings. Reload + the page to try again. +

+
+ ) : ( + <> +
+

People

+ {bindings && bindings.users.length > 0 ? ( +
+ {bindings.users.map((row) => { + // Client-side mirror of the server's "keep one owner" + // guard, so the common case never round-trips to a 400. + const isLastOwner = row.role === "owner" && ownerCount <= 1; + // A non-admin may not drop or demote themselves (the + // server refuses). An admin CAN, for hand-off — the server + // only stops them when it would leave them with no project + // at all, which the client cannot know (it sees one + // project), so that case stays a server 400 and the + // confirmation below spells the risk out. + const isSelfLock = row.userId === userId && !isOrgAdmin; + const locked = isLastOwner || isSelfLock; + const lockReason = isLastOwner + ? "A project must keep at least one owner" + : "You cannot remove your own access to this project"; + const busy = + setAccess.isPending && busyRowId === row.userId; + + return ( +
+
+

+ {row.name ?? row.email} +

+ {row.name && ( +

+ {row.email} +

+ )} +
+ {row.isOwner && ( + + + + Creator + + + + Created this project. Removing their access also + stops their project API key from working. + + + )} + + + + + + + + {locked && ( + {lockReason} + )} + +
+ ); + })} +
+ ) : ( +

+ Nobody has direct access to this project yet. +

+ )} +
+ +
+

Groups

+ {bindings && bindings.groups.length > 0 ? ( +
+ {bindings.groups.map((row) => { + const busy = + setAccess.isPending && busyRowId === row.groupId; + return ( +
+ +
+

+ {row.name} +

+

+ {row.memberCount} member + {row.memberCount === 1 ? "" : "s"} +

+
+ +
+ ); + })} +
+ ) : ( +

+ No groups have access to this project. +

+ )} +

+ Everyone in a group listed here can use the project. Deleting + the group removes that access. +

+
+ + )} +
+ + {sharingEnabled && bindings && ( + + )} + + {/* Removing a binding revokes LIVE authorization — the gateway and the + API both read these rows — so it is confirmed like every other + destructive action in the app, with the concrete consequence named. */} + { + if (!open && !setAccess.isPending) setRemoval(null); + }} + > + + + + {removal?.kind === "group" + ? `Remove ${removal.name}?` + : `Remove ${removal?.label ?? "this person"}?`} + + + {removal?.kind === "group" + ? `All ${removal.memberCount} member${ + removal.memberCount === 1 ? "" : "s" + } of this group lose access to this project immediately, unless they also have direct access.` + : "They lose access to this project immediately, and any project API key they hold stops authenticating."} + {removal?.kind === "user" && removal.isSelf + ? " This is your own access: if this project is the only one you can reach, the API will refuse rather than lock you out." + : ""} + + + + + Cancel + + { + e.preventDefault(); + confirmRemoval(); + }} + disabled={setAccess.isPending} + > + {setAccess.isPending ? ( + <> + + Removing... + + ) : ( + "Remove" + )} + + + + +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/settings/project/_components/project-access-dialog.tsx b/apps/web/src/app/(dashboard)/settings/project/_components/project-access-dialog.tsx new file mode 100644 index 00000000..a0631f8a --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/project/_components/project-access-dialog.tsx @@ -0,0 +1,435 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { Loader2, Search, TriangleAlert, UsersRound } from "lucide-react"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@onecli/ui/components/dialog"; +import { + AnimatedTabs, + AnimatedTabList, + AnimatedTabTrigger, + AnimatedTabContent, +} from "@onecli/ui/components/animated-tabs"; +import { Button } from "@onecli/ui/components/button"; +import { Input } from "@onecli/ui/components/input"; +import { Badge } from "@onecli/ui/components/badge"; +import { Checkbox } from "@onecli/ui/components/checkbox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@onecli/ui/components/select"; +import { ApiError, type ProjectAccessBindings } from "@/lib/api"; +import { useOrgMembersList } from "@/hooks/use-org-members"; +import { useGroups } from "@/hooks/use-groups"; +import { useSetProjectAccess } from "@/hooks/use-project-access"; +import { AdminOnlyNotice } from "./admin-only-notice"; + +export interface ProjectAccessDialogProps { + projectId: string; + /** The bindings the buffer is seeded from — never a failed read. */ + current: ProjectAccessBindings; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +type ManagementRole = "owner" | "member"; + +/** + * Replace-set picker for one project — the group-members dialog extended to two + * candidate feeds (org members and org groups) sharing one edit buffer. Save + * PUTs the exact selection back; there are no per-row endpoints. + */ +export const ProjectAccessDialog = ({ + projectId, + current, + open, + onOpenChange, +}: ProjectAccessDialogProps) => { + const { + data: memberCandidates = [], + isPending: membersPending, + isError: membersError, + error: membersFailure, + } = useOrgMembersList(open); + const { + data: groupCandidates = [], + isPending: groupsPending, + isError: groupsError, + error: groupsFailure, + } = useGroups(open); + const setAccess = useSetProjectAccess(); + + const isPending = membersPending || groupsPending; + // EITHER feed failing must surface as an ERROR, never as an empty baseline: + // this is a replace-set picker, so seeding from a failed read would render + // every real grant unchecked and let one toggle + Save wipe the bindings. + // (`current` is always the live bindings — the card only renders the dialog + // once they loaded.) + const isError = membersError || groupsError; + // A 403 is the EXPECTED admin-only case (a project owner who is not an org + // admin cannot enumerate the directory); anything else is a transport or + // server failure and must not be reported as a permission problem. + const isForbidden = [membersFailure, groupsFailure].some( + (failure) => failure instanceof ApiError && failure.status === 403, + ); + + const [tab, setTab] = useState("people"); + const [users, setUsers] = useState>( + () => new Map(), + ); + const [groupIds, setGroupIds] = useState>(() => new Set()); + const [saving, setSaving] = useState(false); + const [search, setSearch] = useState(""); + + const initialUsers = useMemo( + () => new Map(current.users.map((u) => [u.userId, u.role])), + [current.users], + ); + const initialGroups = useMemo( + () => new Set(current.groups.map((g) => g.groupId)), + [current.groups], + ); + + // Seed the edit buffer once per open, once both feeds settle — guarded so a + // background refetch can't clobber in-progress edits. Search clears on close. + const seededRef = useRef(false); + useEffect(() => { + if (!open) { + seededRef.current = false; + setSearch(""); + setTab("people"); + return; + } + if (seededRef.current || isPending || isError) return; + setUsers(new Map(initialUsers)); + setGroupIds(new Set(initialGroups)); + seededRef.current = true; + }, [open, isPending, isError, initialUsers, initialGroups]); + + const filteredMembers = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return memberCandidates; + return memberCandidates.filter( + (m) => + m.email.toLowerCase().includes(q) || + (m.name ?? "").toLowerCase().includes(q), + ); + }, [memberCandidates, search]); + + const filteredGroups = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return groupCandidates; + return groupCandidates.filter((g) => g.name.toLowerCase().includes(q)); + }, [groupCandidates, search]); + + const dirty = useMemo(() => { + if (users.size !== initialUsers.size) return true; + for (const [id, role] of users) { + if (initialUsers.get(id) !== role) return true; + } + if (groupIds.size !== initialGroups.size) return true; + for (const id of groupIds) if (!initialGroups.has(id)) return true; + return false; + }, [users, groupIds, initialUsers, initialGroups]); + + const hasOwner = [...users.values()].includes("owner"); + + const toggleUser = (userId: string) => { + setUsers((prev) => { + const next = new Map(prev); + if (next.has(userId)) next.delete(userId); + // Checking a person defaults them to a plain use grant; the per-row + // select promotes. + else next.set(userId, "member"); + return next; + }); + }; + + const setUserRole = (userId: string, role: ManagementRole) => { + setUsers((prev) => { + const next = new Map(prev); + if (next.has(userId)) next.set(userId, role); + return next; + }); + }; + + const toggleGroup = (groupId: string) => { + setGroupIds((prev) => { + const next = new Set(prev); + if (next.has(groupId)) next.delete(groupId); + else next.add(groupId); + return next; + }); + }; + + // Select-all/clear act on ALL candidates, not just the filtered view. + const selectAll = () => { + if (tab === "people") { + setUsers((prev) => { + const next = new Map(prev); + for (const m of memberCandidates) { + if (!next.has(m.userId)) next.set(m.userId, "member"); + } + return next; + }); + } else { + setGroupIds(new Set(groupCandidates.map((g) => g.id))); + } + }; + const clearAll = () => { + if (tab === "people") setUsers(new Map()); + else setGroupIds(new Set()); + }; + + const handleSave = async () => { + setSaving(true); + try { + await setAccess.mutateAsync({ + projectId, + users: [...users].map(([userId, role]) => ({ userId, role })), + groupIds: [...groupIds], + }); + onOpenChange(false); + toast.success("Project access updated"); + } catch { + // The mutation hook already toasts the server reason — keep the dialog + // open so the selection isn't lost. + } finally { + setSaving(false); + } + }; + + const selectedCount = tab === "people" ? users.size : groupIds.size; + const candidateCount = + tab === "people" ? memberCandidates.length : groupCandidates.length; + + return ( + + + + Manage project access +

+ Choose who can use this project. Owners can also rename, share and + delete it. Groups grant access to everyone in them. +

+
+ +
+ {isError ? ( + isForbidden ? ( + + ) : ( +
+

+ Couldn't load candidates +

+

+ Something went wrong fetching the organization's members + and groups. Close this dialog and try again. +

+
+ ) + ) : isPending ? ( +
+ +
+ ) : ( + + + People + Groups + + +
+
+
+ +
+

+ + {selectedCount} + {" "} + of {candidateCount} selected +

+
+ + / + +
+
+
+ + {/* A native max-height scroller, as in the group-members dialog: + it shrinks to fit a few rows and caps at the viewport. */} + +
+
+ {filteredMembers.map((row) => ( +
+ toggleUser(row.userId)} + /> + + {row.status === "suspended" && ( + + Suspended + + )} + +
+ ))} + + {filteredMembers.length === 0 && ( +

+ {memberCandidates.length === 0 + ? "Invite teammates from the Team page to share this project." + : `No people match “${search}”`} +

+ )} +
+
+
+ + +
+
+ {filteredGroups.map((row) => ( + + ))} + + {filteredGroups.length === 0 && ( +

+ {groupCandidates.length === 0 + ? "Create a group on the Groups page to share this project with a team." + : `No groups match “${search}”`} +

+ )} +
+
+
+
+ )} +
+ + + {!isError && !isPending && !hasOwner && ( +

+ A project must keep + at least one owner. +

+ )} +
+ + +
+
+
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/settings/project/_components/project-name-card.tsx b/apps/web/src/app/(dashboard)/settings/project/_components/project-name-card.tsx new file mode 100644 index 00000000..53ab6fc5 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/project/_components/project-name-card.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@onecli/ui/components/card"; +import { Button } from "@onecli/ui/components/button"; +import { Input } from "@onecli/ui/components/input"; +import { Label } from "@onecli/ui/components/label"; +import type { Project } from "@/lib/api"; +import { queryKeys } from "@/lib/api/keys"; +import { useRenameProject } from "@/hooks/use-projects"; + +export interface ProjectNameCardProps { + project: Project; + canManage: boolean; +} + +export const ProjectNameCard = ({ + project, + canManage, +}: ProjectNameCardProps) => { + const [name, setName] = useState(project.name ?? ""); + const rename = useRenameProject(); + const qc = useQueryClient(); + + const trimmed = name.trim(); + const error = + trimmed.length === 0 + ? "Name is required." + : trimmed.length > 100 + ? "Name must be 100 characters or fewer." + : null; + const dirty = trimmed !== (project.name ?? ""); + + const handleSave = () => { + if (error || !dirty || rename.isPending) return; + rename.mutate( + { id: project.id, name: trimmed }, + { + onSuccess: () => { + // The rename hook deliberately owns no cache, so the invalidation + // lives with the component that knows which query it fed. + qc.invalidateQueries({ + queryKey: queryKeys.projects.detail(project.id), + }); + toast.success("Project renamed"); + }, + }, + ); + }; + + return ( + + + Name + + How this project appears across the dashboard. Names do not have to be + unique. + + + +
+ + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSave(); + }} + /> + {error && dirty && ( +

{error}

+ )} +
+ +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/settings/project/_components/project-settings-content.tsx b/apps/web/src/app/(dashboard)/settings/project/_components/project-settings-content.tsx new file mode 100644 index 00000000..3999be51 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/project/_components/project-settings-content.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { Card } from "@onecli/ui/components/card"; +import { Skeleton } from "@onecli/ui/components/skeleton"; +import { useProject } from "@/hooks/use-projects"; +import { useProjectAccess } from "@/hooks/use-project-access"; +import { useOrgMembersList } from "@/hooks/use-org-members"; +import { ProjectNameCard } from "./project-name-card"; +import { ProjectAccessCard } from "./project-access-card"; +import { DeleteProjectCard } from "./delete-project-card"; +import { ReadOnlyNotice } from "./read-only-notice"; + +export interface ProjectSettingsContentProps { + projectId: string; + /** The signed-in user's DB id — the same id `ProjectAccessUserRow` carries. */ + userId: string; + /** Threaded from the RSC page (server-only auth mode); false = local mode. */ + sharingEnabled: boolean; +} + +export const ProjectSettingsContent = ({ + projectId, + userId, + sharingEnabled, +}: ProjectSettingsContentProps) => { + const project = useProject(projectId); + const access = useProjectAccess(projectId, sharingEnabled); + // Doubles as the ADMIN PROBE and as the sharing dialog's candidate feed (one + // query key, so the dialog reuses this fetch). `/v1/org/members` is + // admin-only, so a success means "org admin" and a 403 means "not". + const orgMembers = useOrgMembersList(sharingEnabled); + + // `canManage` is a DISPLAY hint, never an authorization decision — the API's + // 403 is the authority, and every mutation surfaces its message as a toast. + // Both signals come from data already fetched: an owner binding of my own, or + // a successful admin-only directory read. + const isOrgAdmin = orgMembers.isSuccess; + const holdsOwnerBinding = Boolean( + access.data?.users.some((u) => u.userId === userId && u.role === "owner"), + ); + // LOCAL MODE (`!sharingEnabled`, the default OSS self-host) short-circuits: + // both probes above are disabled queries there, so neither can ever answer. + // That single built-in identity is the organization's owner, so rename and + // delete stay live — only the sharing card degrades. The API still decides: + // `canManageProject` resolves the local identity's org role through the + // ossRoleResolver, and its 403 would surface as a toast. + const canManage = !sharingEnabled || isOrgAdmin || holdsOwnerBinding; + + // Both probes are also the reason the page waits: rendering before they + // settle would flash every control disabled for a legitimate owner (an + // orgMembers 403 settles as `isError`, so a non-admin does not wait twice). + const probesPending = + sharingEnabled && (orgMembers.isPending || access.isPending); + + if (project.isPending || probesPending) { + return ( + <> + {[1, 2, 3].map((i) => ( + +
+ + + +
+
+ ))} + + ); + } + + if (project.isError || !project.data) { + // A plain card: no retry, no toast — the failure is deterministic. + return ( + +

Couldn't load this project

+

+ Something went wrong fetching the project. Reload the page to try + again. +

+
+ ); + } + + return ( + <> + {!canManage && } + + + + + ); +}; diff --git a/apps/web/src/app/(dashboard)/settings/project/_components/read-only-notice.tsx b/apps/web/src/app/(dashboard)/settings/project/_components/read-only-notice.tsx new file mode 100644 index 00000000..4784bcee --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/project/_components/read-only-notice.tsx @@ -0,0 +1,20 @@ +import { Eye } from "lucide-react"; + +/** + * Rendered when the signed-in user may USE this project but not manage it — a + * member holding a plain use grant. Without it the page is a wall of silently + * disabled controls (the /team and /groups pages surface the same distinction + * with their admin-only notice). + */ +export const ReadOnlyNotice = () => ( +
+ +
+

You can view these settings

+

+ Only a project owner or an organization admin can rename this project, + change who can use it, or delete it. +

+
+
+); diff --git a/apps/web/src/app/(dashboard)/settings/project/loading.tsx b/apps/web/src/app/(dashboard)/settings/project/loading.tsx new file mode 100644 index 00000000..8827ada1 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/project/loading.tsx @@ -0,0 +1,23 @@ +import { Card } from "@onecli/ui/components/card"; +import { Skeleton } from "@onecli/ui/components/skeleton"; +import { PageHeader } from "@dashboard/page-header"; + +export default function ProjectSettingsLoading() { + return ( +
+ + {[1, 2, 3].map((i) => ( + +
+ + + +
+
+ ))} +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/settings/project/page.tsx b/apps/web/src/app/(dashboard)/settings/project/page.tsx new file mode 100644 index 00000000..c0ecaed1 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/project/page.tsx @@ -0,0 +1,37 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; +import { PageHeader } from "@dashboard/page-header"; +import { getAuthMode } from "@/lib/auth/auth-mode"; +import { resolveProjectContext } from "@/lib/actions/resolve-user"; +import { ProjectSettingsContent } from "./_components/project-settings-content"; + +export const metadata: Metadata = { + title: "Project", +}; + +export default async function ProjectSettingsPage() { + // Auth mode is server-only (fs-backed runtime config), so it is resolved here + // and threaded down (the /groups + /team precedent). Local mode has exactly + // one identity, so sharing is inert — rename and delete stay live. + const sharingEnabled = getAuthMode() !== "local"; + // OSS sends no `X-Project-Id`, and the client session carries no project id, + // so the active project is resolved here — through the SAME helper the server + // actions use, which gates identically to the API's `resolveProjectId`. + const { projectId, userId } = await resolveProjectContext(); + + return ( +
+ + + + +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/deliveries-table.tsx b/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/deliveries-table.tsx new file mode 100644 index 00000000..1f5495b7 --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/deliveries-table.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { Card } from "@onecli/ui/components/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@onecli/ui/components/table"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@onecli/ui/components/tooltip"; +import { formatRelative, formatUTC } from "@onecli/api/lib/format"; +import type { WebhookDelivery } from "@/lib/api"; +import { DeliveryStatusBadge } from "./delivery-status-badge"; + +export interface DeliveriesTableProps { + deliveries: WebhookDelivery[]; + onRowClick: (delivery: WebhookDelivery) => void; + emptyMessage: string; +} + +const DateCell = ({ value }: { value: string }) => ( + + + + {formatRelative(value)} + + + +

{formatUTC(value)}

+

+ {new Date(value).toLocaleString()} +

+
+
+); + +export const DeliveriesTable = ({ + deliveries, + onRowClick, + emptyMessage, +}: DeliveriesTableProps) => { + if (deliveries.length === 0) { + return ( + +

{emptyMessage}

+
+ ); + } + + return ( + + + + + Received + Status + Event + Attempts + Error + + + + {deliveries.map((delivery) => ( + onRowClick(delivery)} + > + + + + + + + + {delivery.event ?? ( + + )} + {delivery.replayOfId && ( + + (replay) + + )} + {delivery.duplicateCount > 0 && ( + + +{delivery.duplicateCount} duplicate + {delivery.duplicateCount === 1 ? "" : "s"} + + )} + + + {delivery.attempts} + + + {delivery.lastError} + + + ))} + +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/delivery-detail-dialog.tsx b/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/delivery-detail-dialog.tsx new file mode 100644 index 00000000..5b702469 --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/delivery-detail-dialog.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { Button } from "@onecli/ui/components/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@onecli/ui/components/dialog"; +import { Skeleton } from "@onecli/ui/components/skeleton"; +import { formatUTC } from "@onecli/api/lib/format"; +import { useDeliveryDetail, useReplayDelivery } from "@/hooks/use-webhooks"; +import type { WebhookDelivery } from "@/lib/api"; +import { DeliveryStatusBadge } from "./delivery-status-badge"; + +export interface DeliveryDetailDialogProps { + delivery: WebhookDelivery | null; + hookId: string; + onClose: () => void; +} + +const Row = ({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) => ( +
+ {label} +
{children}
+
+); + +const Block = ({ label, children }: { label: string; children: string }) => ( +
+ {label} +
+      {children}
+    
+
+); + +export const DeliveryDetailDialog = ({ + delivery, + hookId, + onClose, +}: DeliveryDetailDialogProps) => { + // The list row carries no payload — fetch the full record on open. + const detail = useDeliveryDetail(delivery?.id ?? null); + const replay = useReplayDelivery(hookId); + + // A rejected delivery was never stored with its payload, so there is nothing + // to re-render. + const replayable = delivery?.discardReason !== "rejected"; + + return ( + onClose()}> + + + Delivery + + + {detail.isPending ? ( +
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+ ) : detail.isError ? ( +

+ {detail.error.message} +

+ ) : ( + detail.data && ( +
+ + + + + + {formatUTC(detail.data.receivedAt)} + + + {detail.data.event ?? "—"} + + {detail.data.id} + + {detail.data.dedupeKey && ( + + + {detail.data.dedupeKey} + + + )} + + {detail.data.attempts} + + {detail.data.deliveredAt && ( + + + {formatUTC(detail.data.deliveredAt)} + + + )} + {detail.data.claimedBy && ( + + + {detail.data.claimedBy} + + + )} + {detail.data.replayOfId && ( + + + {detail.data.replayOfId} + + + )} + {/* The reason a consumer rejected this — the whole point of the + nack contract, readable without SSH access to the runtime. */} + {detail.data.lastError && ( + + + {detail.data.lastError} + + + )} + {detail.data.renderWarnings.length > 0 && ( + + + {detail.data.renderWarnings.join(", ")} + + + )} + + {detail.data.renderedText && ( + {detail.data.renderedText} + )} + + {JSON.stringify(detail.data.headers, null, 2)} + + + {detail.data.payload === null + ? "Not stored — this request failed verification." + : JSON.stringify(detail.data.payload, null, 2)} + +
+ ) + )} + + + {replayable && delivery && ( + + )} + + +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/delivery-status-badge.tsx b/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/delivery-status-badge.tsx new file mode 100644 index 00000000..f7e03cae --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/delivery-status-badge.tsx @@ -0,0 +1,73 @@ +import { + CircleCheck, + CircleX, + Clock, + Inbox, + Loader2, + ShieldX, +} from "lucide-react"; +import type { WebhookDelivery } from "@/lib/api"; + +export interface DeliveryStatusBadgeProps { + delivery: Pick; +} + +/** + * Same idiom as the activity log's `StatusBadge` — a span plus a lucide icon, + * not the `Badge` component. + * + * "In flight" is not a stored status: the server derives it from a pending row + * that holds a live claim, which is why it is checked before `status` here. + */ +export const DeliveryStatusBadge = ({ delivery }: DeliveryStatusBadgeProps) => { + if (delivery.inFlight) { + return ( + + + In flight + + ); + } + + if (delivery.status === "delivered") { + return ( + + + Delivered + + ); + } + + if (delivery.status === "failed") { + return ( + + + Failed + + ); + } + + if (delivery.status === "discarded") { + if (delivery.discardReason === "rejected") { + return ( + + + Rejected + + ); + } + return ( + + + {delivery.discardReason === "handshake" ? "Handshake" : "Ignored"} + + ); + } + + return ( + + + Queued + + ); +}; diff --git a/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/webhook-deliveries.tsx b/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/webhook-deliveries.tsx new file mode 100644 index 00000000..ba98d35b --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/webhook-deliveries.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Loader2, Radio } from "lucide-react"; +import { Button } from "@onecli/ui/components/button"; +import { webhooks } from "@/lib/api"; +import type { WebhookDelivery, WebhookDeliveryPage } from "@/lib/api"; +import { DeliveriesTable } from "./deliveries-table"; +import { DeliveryDetailDialog } from "./delivery-detail-dialog"; + +export interface WebhookDeliveriesProps { + hookId: string; +} + +/** + * A port of the activity log's paging + live-poll shape. + * + * Plain `useState` rather than `useInfiniteQuery` on purpose: the live poll + * replaces page 0 in place while keeping appended pages, which does not map + * cleanly onto infinite-query pages — and the activity log is the design + * authority for this surface. + */ +export const WebhookDeliveries = ({ hookId }: WebhookDeliveriesProps) => { + const [deliveries, setDeliveries] = useState([]); + const [nextCursor, setNextCursor] = + useState(null); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [liveMode, setLiveMode] = useState(true); + const [selected, setSelected] = useState(null); + const initialized = useRef(false); + + const loadInitial = useCallback(async () => { + setLoading(true); + try { + const page = await webhooks.deliveries(hookId); + setDeliveries(page.deliveries); + setNextCursor(page.nextCursor); + initialized.current = true; + } finally { + setLoading(false); + } + }, [hookId]); + + useEffect(() => { + initialized.current = false; + void loadInitial(); + }, [loadInitial]); + + useEffect(() => { + if (!liveMode || loading) return; + const timer = setInterval(async () => { + if (!initialized.current) return; + try { + const page = await webhooks.deliveries(hookId); + setDeliveries((prev) => { + // Identity check: an unchanged head means React can skip the render + // entirely, which matters at a 3s cadence. + if ( + prev.length === page.deliveries.length && + prev[0]?.id === page.deliveries[0]?.id && + prev[0]?.status === page.deliveries[0]?.status + ) { + return prev; + } + return page.deliveries; + }); + setNextCursor(page.nextCursor); + } catch { + // Best-effort polling: a transient failure should not clear the table. + } + }, 3000); + return () => clearInterval(timer); + }, [liveMode, loading, hookId]); + + const loadMore = async () => { + if (!nextCursor) return; + // Paging and live-replacing page 0 fight each other; paging wins. + setLiveMode(false); + setLoadingMore(true); + try { + const page = await webhooks.deliveries(hookId, { + cursorCreatedAt: nextCursor.createdAt, + cursorId: nextCursor.id, + }); + setDeliveries((prev) => [...prev, ...page.deliveries]); + setNextCursor(page.nextCursor); + } finally { + setLoadingMore(false); + } + }; + + return ( +
+
+

Deliveries

+ +
+ + {loading ? ( +
+ +
+ ) : ( + <> + + {nextCursor && ( +
+ +
+ )} + + )} + + setSelected(null)} + /> +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/webhook-detail-content.tsx b/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/webhook-detail-content.tsx new file mode 100644 index 00000000..de3421b0 --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/webhook-detail-content.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { usePathname, useRouter } from "next/navigation"; +import { Badge } from "@onecli/ui/components/badge"; +import { Card } from "@onecli/ui/components/card"; +import { Skeleton } from "@onecli/ui/components/skeleton"; +import { PageHeader } from "@dashboard/page-header"; +import { useWebhook } from "@/hooks/use-webhooks"; +import { usePublicBaseUrl } from "@/hooks/use-public-base-url"; +import { withProjectPrefix } from "@/lib/navigation"; +import { WebhookRowActions } from "../../_components/webhook-row-actions"; +import { WebhookDeliveries } from "./webhook-deliveries"; +import { WebhookSummaryCard } from "./webhook-summary-card"; + +export interface WebhookDetailContentProps { + hookId: string; + publicBaseUrl: string; +} + +export const WebhookDetailContent = ({ + hookId, + publicBaseUrl, +}: WebhookDetailContentProps) => { + const endpoint = useWebhook(hookId); + const baseUrl = usePublicBaseUrl(publicBaseUrl); + const router = useRouter(); + const pathname = usePathname(); + + if (endpoint.isPending) { + return ( +
+ + + + +
+ ); + } + + if (endpoint.isError) { + return ( + +

Webhook not found

+

+ {endpoint.error.message} +

+
+ ); + } + + return ( + <> +
+ +
+ {!endpoint.data.enabled && Disabled} + + router.push(withProjectPrefix(pathname, "/webhooks")) + } + /> +
+
+ + + + + + ); +}; diff --git a/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/webhook-summary-card.tsx b/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/webhook-summary-card.tsx new file mode 100644 index 00000000..b1651c1b --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/[hookId]/_components/webhook-summary-card.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { Card } from "@onecli/ui/components/card"; +import type { WebhookEndpointWithSecret } from "@/lib/api"; +import { IngestUrlPanel } from "../../_components/ingest-url-panel"; + +export interface WebhookSummaryCardProps { + endpoint: WebhookEndpointWithSecret; + ingestUrl: string; +} + +const Field = ({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) => ( +
+

{label}

+
{children}
+
+); + +export const WebhookSummaryCard = ({ + endpoint, + ingestUrl, +}: WebhookSummaryCardProps) => ( +
+ + + + + + + {endpoint.agentName}{" "} + + {endpoint.agentIdentifier} + + + + +
+          {endpoint.template.trim() === ""
+            ? "(default: the slug, the event, and the raw payload)"
+            : endpoint.template}
+        
+
+ + + {endpoint.routing ? ( +
+            {JSON.stringify(endpoint.routing, null, 2)}
+          
+ ) : ( + + None — the consumer decides what to do with each delivery. + + )} +
+ + + + {endpoint.rateLimitPerMin} requests per minute + + +
+
+); diff --git a/apps/web/src/app/(dashboard)/webhooks/[hookId]/loading.tsx b/apps/web/src/app/(dashboard)/webhooks/[hookId]/loading.tsx new file mode 100644 index 00000000..c343b502 --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/[hookId]/loading.tsx @@ -0,0 +1,27 @@ +import { Card } from "@onecli/ui/components/card"; +import { Skeleton } from "@onecli/ui/components/skeleton"; + +export default function Loading() { + return ( +
+
+ + +
+ +
+ {[1, 2, 3].map((i) => ( + + ))} +
+
+ +
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/webhooks/[hookId]/page.tsx b/apps/web/src/app/(dashboard)/webhooks/[hookId]/page.tsx new file mode 100644 index 00000000..f9ea0280 --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/[hookId]/page.tsx @@ -0,0 +1,31 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; +import { headers } from "next/headers"; +import { + configuredAppUrl, + originFromHeaders, +} from "@onecli/api/lib/app-origin"; +import { APP_URL } from "@/lib/env"; +import { WebhookDetailContent } from "./_components/webhook-detail-content"; + +export const metadata: Metadata = { + title: "Webhook", +}; + +interface Props { + params: Promise<{ hookId: string }>; +} + +export default async function WebhookDetailPage({ params }: Props) { + const { hookId } = await params; + const publicBaseUrl = + configuredAppUrl() ?? originFromHeaders(await headers()) ?? APP_URL; + + return ( +
+ + + +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/webhooks/_components/create-webhook-dialog.tsx b/apps/web/src/app/(dashboard)/webhooks/_components/create-webhook-dialog.tsx new file mode 100644 index 00000000..58938246 --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/_components/create-webhook-dialog.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@onecli/ui/components/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@onecli/ui/components/dialog"; +import { useCreateWebhook } from "@/hooks/use-webhooks"; +import { usePublicBaseUrl } from "@/hooks/use-public-base-url"; +import type { WebhookEndpointWithSecret } from "@/lib/api"; +import { IngestUrlPanel } from "./ingest-url-panel"; +import { + validateWebhookForm, + WebhookFormFields, + type WebhookFormValues, +} from "./webhook-form-fields"; + +export interface CreateWebhookDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + publicBaseUrl: string; +} + +const EMPTY: WebhookFormValues = { + name: "", + slug: "", + agentId: "", + verification: "github", + template: "", + routing: "", + enabled: true, +}; + +export const CreateWebhookDialog = ({ + open, + onOpenChange, + publicBaseUrl, +}: CreateWebhookDialogProps) => { + const [values, setValues] = useState(EMPTY); + const [touched, setTouched] = useState(false); + // Two-step: the form, then the setup panel. The URL and secret are the whole + // point of creating one, so they get a screen rather than a toast. + const [created, setCreated] = useState( + null, + ); + const create = useCreateWebhook(); + + const baseUrl = usePublicBaseUrl(publicBaseUrl); + const errors = validateWebhookForm(values); + const hasError = Object.values(errors).some(Boolean); + + const handleClose = (next: boolean) => { + if (!next) { + setValues(EMPTY); + setTouched(false); + setCreated(null); + } + onOpenChange(next); + }; + + const handleCreate = () => { + setTouched(true); + if (hasError || create.isPending) return; + + create.mutate( + { + name: values.name.trim(), + slug: values.slug.trim(), + agentId: values.agentId, + verification: values.verification, + template: values.template, + routing: + values.routing.trim() === "" + ? null + : (JSON.parse(values.routing) as Record), + ...(values.verification === "none" + ? { acknowledgeUnverified: true } + : {}), + }, + { onSuccess: setCreated }, + ); + }; + + return ( + + + {created ? ( + <> + + {created.name} is ready + + Configure your provider with the URL below. Deliveries appear in + this endpoint's log as they arrive. + + +
+ +
+ + + + + ) : ( + <> + + Create webhook endpoint + + OneCLI verifies each delivery, renders your template, and queues + it for the agent to pick up. + + +
+ + setValues((current) => ({ ...current, ...patch })) + } + /> +
+ + + + + + )} +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/webhooks/_components/edit-webhook-dialog.tsx b/apps/web/src/app/(dashboard)/webhooks/_components/edit-webhook-dialog.tsx new file mode 100644 index 00000000..2b61229d --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/_components/edit-webhook-dialog.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button } from "@onecli/ui/components/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@onecli/ui/components/dialog"; +import { useUpdateWebhook } from "@/hooks/use-webhooks"; +import type { WebhookEndpoint } from "@/lib/api"; +import { + validateWebhookForm, + WebhookFormFields, + type WebhookFormValues, +} from "./webhook-form-fields"; + +export interface EditWebhookDialogProps { + endpoint: WebhookEndpoint; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const seed = (endpoint: WebhookEndpoint): WebhookFormValues => ({ + name: endpoint.name, + slug: endpoint.slug, + agentId: endpoint.agentId, + verification: endpoint.verification, + template: endpoint.template, + routing: endpoint.routing ? JSON.stringify(endpoint.routing, null, 2) : "", + enabled: endpoint.enabled, +}); + +export const EditWebhookDialog = ({ + endpoint, + open, + onOpenChange, +}: EditWebhookDialogProps) => { + const [values, setValues] = useState(() => seed(endpoint)); + const [touched, setTouched] = useState(false); + const update = useUpdateWebhook(); + + // Re-seed on open so a cancelled edit does not persist into the next one. + useEffect(() => { + if (open) { + setValues(seed(endpoint)); + setTouched(false); + } + }, [open, endpoint]); + + const errors = validateWebhookForm(values); + const hasError = Object.values(errors).some(Boolean); + const verificationChanged = values.verification !== endpoint.verification; + + const handleSave = () => { + setTouched(true); + if (hasError || update.isPending) return; + + update.mutate( + { + hookId: endpoint.id, + input: { + name: values.name.trim(), + slug: values.slug.trim(), + agentId: values.agentId, + verification: values.verification, + template: values.template, + routing: + values.routing.trim() === "" + ? null + : (JSON.parse(values.routing) as Record), + enabled: values.enabled, + ...(values.verification === "none" + ? { acknowledgeUnverified: true } + : {}), + }, + }, + { onSuccess: () => onOpenChange(false) }, + ); + }; + + return ( + + + + Edit {endpoint.name} + + Template changes apply to new deliveries, and to any delivery you + replay. + + +
+ + setValues((current) => ({ ...current, ...patch })) + } + showEnabled + /> + {/* A GitHub HMAC key and a shared token are not interchangeable, so + the server mints a new secret when this changes. Saying so here + beats discovering it when the provider starts getting 401s. */} + {verificationChanged && ( +

+ Changing verification issues a new secret. Update it at the + provider immediately, or deliveries will be rejected. +

+ )} +
+ + + + +
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/webhooks/_components/ingest-url-panel.tsx b/apps/web/src/app/(dashboard)/webhooks/_components/ingest-url-panel.tsx new file mode 100644 index 00000000..e6ec481e --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/_components/ingest-url-panel.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { Check, Copy, Eye, EyeOff } from "lucide-react"; +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; +import { IS_CLOUD } from "@/lib/env"; +import { TestCommand } from "./test-command"; + +export interface IngestUrlPanelProps { + ingestUrl: string; + verification: string; + secret: string | null; +} + +const CopyButton = ({ value }: { value: string }) => { + const { copied, copy } = useCopyToClipboard(); + return ( + + ); +}; + +const SECRET_LABELS: Record = { + github: "Paste into GitHub's Secret field.", + token: "Send as an X-Webhook-Token header, or as ?token= on the URL.", +}; + +/** + * The setup checklist, top to bottom: where to paste the URL, the secret that + * goes with it, the content type that trips everyone up, and a command to prove + * the whole path works. + */ +export const IngestUrlPanel = ({ + ingestUrl, + verification, + secret, +}: IngestUrlPanelProps) => { + const [revealed, setRevealed] = useState(false); + + const masked = secret + ? `${secret.slice(0, 6)}${"•".repeat(12)}${secret.slice(-4)}` + : ""; + + return ( +
+
+

Payload URL

+
+ + {ingestUrl} + + +
+

+ Paste this into your provider's webhook configuration — in + GitHub, Settings → Webhooks → Payload URL. + {!IS_CLOUD && ( + <> + {" "} + + Check your public URL + {" "} + if this host is not reachable from the internet. + + )} +

+
+ + {secret ? ( +
+

Secret

+
+ + {revealed ? secret : masked} + + + +
+

+ {SECRET_LABELS[verification] ?? "Send with every request."} +

+
+ ) : ( +
+

+ This endpoint accepts any request that reaches the URL. The URL is + the only secret — do not paste it into an issue or a screenshot. +

+
+ )} + + {/* The single most common setup failure: GitHub defaults to + form-encoded, and a sender that posts something else gets a 415. */} +
+

Content type

+

+ Choose application/json. GitHub + defaults to form-urlencoded, which + works too, but JSON is what every template example assumes. +

+
+ + +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/webhooks/_components/test-command.tsx b/apps/web/src/app/(dashboard)/webhooks/_components/test-command.tsx new file mode 100644 index 00000000..9a5f379d --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/_components/test-command.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { Check, Copy } from "lucide-react"; +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; + +export interface TestCommandProps { + ingestUrl: string; + verification: string; + secret: string | null; +} + +/** + * A copyable curl rather than an in-app "send test" button. + * + * Deliberate: a server-side self-POST to our own public URL is an SSRF-shaped + * surface, and a client-side one would need CORS plus an HMAC signing oracle in + * the dashboard. More importantly, this tests the thing that actually breaks — + * whether the provider's network can reach this origin at all — which an + * internal test-send would bypass while showing a reassuring green. + */ +const buildCommand = ( + ingestUrl: string, + verification: string, + secret: string | null, +): string => { + if (verification === "github") { + return [ + `BODY='{"action":"opened","repository":{"full_name":"acme/api"}}'`, + `SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac '${secret ?? ""}' -r | cut -d' ' -f1)`, + `curl -sS -X POST '${ingestUrl}' \\`, + ` -H "X-Hub-Signature-256: sha256=$SIG" \\`, + ` -H 'X-GitHub-Event: issues' \\`, + ` -H 'X-GitHub-Delivery: test-1' \\`, + ` -H 'Content-Type: application/json' \\`, + ` --data-binary "$BODY"`, + ].join("\n"); + } + + const auth = + verification === "token" + ? ` -H 'X-Webhook-Token: ${secret ?? ""}' \\\n` + : ""; + return ( + `curl -sS -X POST '${ingestUrl}' \\\n` + + auth + + ` -H 'Content-Type: application/json' \\\n` + + ` --data-binary '{"hello":"world"}'` + ); +}; + +export const TestCommand = ({ + ingestUrl, + verification, + secret, +}: TestCommandProps) => { + const { copied, copy } = useCopyToClipboard(); + const command = buildCommand(ingestUrl, verification, secret); + + return ( +
+
+

Test it

+ +
+
+        {command}
+      
+ {verification === "github" && ( +

+ Note the printf and{" "} + --data-binary: the signature covers + the exact bytes, so a trailing newline from{" "} + echo breaks it. +

+ )} +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/webhooks/_components/verification-badge.tsx b/apps/web/src/app/(dashboard)/webhooks/_components/verification-badge.tsx new file mode 100644 index 00000000..c9344a3a --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/_components/verification-badge.tsx @@ -0,0 +1,38 @@ +import { KeyRound, ShieldAlert, ShieldCheck } from "lucide-react"; + +export interface VerificationBadgeProps { + verification: string; +} + +/** + * Follows the activity log's badge idiom — a span plus a lucide icon, not the + * `Badge` component — so the two log-shaped surfaces read the same. + */ +export const VerificationBadge = ({ verification }: VerificationBadgeProps) => { + if (verification === "github") { + return ( + + + GitHub HMAC + + ); + } + + if (verification === "token") { + return ( + + + Shared token + + ); + } + + // Worth flagging every time it is rendered: with no verification, anyone who + // learns the URL can post to it. + return ( + + + Unverified + + ); +}; diff --git a/apps/web/src/app/(dashboard)/webhooks/_components/webhook-form-fields.tsx b/apps/web/src/app/(dashboard)/webhooks/_components/webhook-form-fields.tsx new file mode 100644 index 00000000..0cc50ea5 --- /dev/null +++ b/apps/web/src/app/(dashboard)/webhooks/_components/webhook-form-fields.tsx @@ -0,0 +1,259 @@ +"use client"; + +import { Input } from "@onecli/ui/components/input"; +import { Label } from "@onecli/ui/components/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@onecli/ui/components/select"; +import { Switch } from "@onecli/ui/components/switch"; +import { Textarea } from "@onecli/ui/components/textarea"; +import { cn } from "@onecli/ui/lib/utils"; +import { useAgents } from "@/hooks/use-agents"; +import { useWebhookVerifiers } from "@/hooks/use-webhooks"; + +export interface WebhookFormValues { + name: string; + slug: string; + agentId: string; + verification: string; + template: string; + routing: string; + enabled: boolean; +} + +export interface WebhookFormErrors { + name?: string | null; + slug?: string | null; + agentId?: string | null; + routing?: string | null; +} + +export interface WebhookFormFieldsProps { + values: WebhookFormValues; + errors: WebhookFormErrors; + touched: boolean; + onChange: (patch: Partial) => void; + /** The enabled switch only makes sense once an endpoint exists. */ + showEnabled?: boolean; + idPrefix: string; +} + +/** + * Validation lives here as plain predicates rather than a resolver — this app + * has no react-hook-form and no zodResolver, and the server's Zod schema is the + * authority regardless. These checks exist to avoid a round trip, not to be the + * gate. + */ +export const validateWebhookForm = ( + values: WebhookFormValues, +): WebhookFormErrors => ({ + name: + values.name.trim().length === 0 + ? "Name is required." + : values.name.trim().length > 100 + ? "Name must be 100 characters or fewer." + : null, + slug: !/^[a-z0-9][a-z0-9-]*$/.test(values.slug.trim()) + ? "Lowercase letters, digits and dashes only." + : null, + agentId: values.agentId === "" ? "Choose the agent to wake." : null, + routing: routingError(values.routing), +}); + +/** + * The routing blob is checked for being parseable JSON and nothing else. + * OneCLI never interprets it — validating its shape here would quietly couple + * the dashboard to one consumer's schema. + */ +const routingError = (routing: string): string | null => { + if (routing.trim() === "") return null; + try { + const parsed: unknown = JSON.parse(routing); + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) + ) { + return "Routing must be a JSON object."; + } + return null; + } catch { + return "Routing must be valid JSON."; + } +}; + +export const WebhookFormFields = ({ + values, + errors, + touched, + onChange, + showEnabled = false, + idPrefix, +}: WebhookFormFieldsProps) => { + const agents = useAgents(); + const verifiers = useWebhookVerifiers(); + const show = (error?: string | null) => touched && Boolean(error); + + return ( +
+
+ + onChange({ name: e.target.value })} + autoFocus + className={cn(show(errors.name) && "border-destructive")} + /> + {show(errors.name) && ( +

{errors.name}

+ )} +
+ +
+ + onChange({ slug: e.target.value })} + className={cn("font-mono", show(errors.slug) && "border-destructive")} + /> +

+ Identifies the webhook to the agent, and available in templates as{" "} + {"{{$slug}}"}. +

+ {show(errors.slug) && ( +

{errors.slug}

+ )} +
+ +
+ + + {values.verification === "none" && ( +

+ Anyone who learns this URL can trigger the agent. Treat it like a + password and prefer a signed or token-verified sender. +

+ )} +
+ +
+ + + {show(errors.agentId) && ( +

{errors.agentId}

+ )} +
+ +
+ +