diff --git a/apps/gateway/Cargo.lock b/apps/gateway/Cargo.lock index 6e62c693..94993dc4 100644 --- a/apps/gateway/Cargo.lock +++ b/apps/gateway/Cargo.lock @@ -207,6 +207,45 @@ dependencies = [ "zeroize", ] +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-lock" version = "3.4.2" @@ -1174,6 +1213,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -2179,6 +2232,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.1.1" @@ -2245,6 +2304,16 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2316,6 +2385,15 @@ dependencies = [ "libm", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2376,6 +2454,7 @@ dependencies = [ "tracing-subscriber", "uuid", "webpki-roots 0.26.11", + "x509-parser", ] [[package]] @@ -2925,6 +3004,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.4" @@ -4613,6 +4701,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "xmlparser" version = "0.13.6" diff --git a/apps/gateway/Cargo.toml b/apps/gateway/Cargo.toml index ba91eeb1..3d2c37c4 100644 --- a/apps/gateway/Cargo.toml +++ b/apps/gateway/Cargo.toml @@ -26,6 +26,9 @@ tokio-rustls = "0.26" rustls = { version = "0.23", features = ["ring"] } rustls-pemfile = "2" +# X.509 parsing (mTLS client-certificate identity extraction — CN/URI SANs) +x509-parser = "0.16" + # WebSocket upstream TLS root certificates webpki-roots = "0.26" diff --git a/apps/gateway/src/ca.rs b/apps/gateway/src/ca.rs index 19872003..496a8e67 100644 --- a/apps/gateway/src/ca.rs +++ b/apps/gateway/src/ca.rs @@ -125,6 +125,14 @@ impl CertificateAuthority { der_to_pem(self.ca_cert_der.as_ref()) } + /// Return the raw CA certificate DER. + /// Used by `client_ca::MtlsConfig::from_env` to reject a `GATEWAY_CLIENT_CA` + /// that is (accidentally or maliciously) this same CA — see the SECURITY + /// note in `client_ca.rs`. + pub(crate) fn ca_cert_der(&self) -> &CertificateDer<'static> { + &self.ca_cert_der + } + /// Load CA from PEM strings (key + certificate). /// Used when CA is provided via environment variables (cloud mode). fn load_from_pem(key_pem: &str, cert_pem: &str) -> Result { @@ -333,9 +341,11 @@ mod tests { fn ensure_crypto_provider() { INIT_CRYPTO.call_once(|| { - rustls::crypto::ring::default_provider() - .install_default() - .expect("install CryptoProvider"); + // Ignore the error: it just means another test module (e.g. + // `client_ca`) already installed the process-wide default in this + // same test binary — a no-op for our purposes either way, since + // it's the same `ring` provider. + let _ = rustls::crypto::ring::default_provider().install_default(); }); } diff --git a/apps/gateway/src/client_ca.rs b/apps/gateway/src/client_ca.rs new file mode 100644 index 00000000..2800ae57 --- /dev/null +++ b/apps/gateway/src/client_ca.rs @@ -0,0 +1,1138 @@ +//! mTLS client-certificate support: config assembly, TLS server config +//! construction, and identity extraction from the client certificate chain. +//! +//! Phase 1 (this module): the gateway can *require* a client certificate on a +//! dedicated port and *extract* an identity from it (CN / URI SAN), but never +//! compares that identity to anything — it's threaded onto [`crate::gateway::ProxyContext`] +//! and logged, nothing more. Phase 2 wires the actual enforcement (comparing +//! `client_identity` against `agent_token`) and CRL support (see the +//! `with_crls` hook noted in `build_server_config`). +//! +//! mTLS is entirely opt-in: unset `GATEWAY_MTLS_PORT` and this module never +//! touches a socket. That keeps the OSS build (and every existing deployment) +//! byte-for-byte backward compatible. + +use std::net::{IpAddr, Ipv4Addr}; +use std::sync::Arc; + +use anyhow::{bail, Context, Result}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer}; +use rustls::server::WebPkiClientVerifier; +use rustls::{RootCertStore, ServerConfig}; + +// ── Identity ───────────────────────────────────────────────────────────── + +/// Identity extracted from a client certificate that already passed the TLS +/// verifier's chain-of-trust and expiry checks. +/// +/// Phase 1 only threads and logs this value — see the module doc. `primary()` +/// is the field Phase 2 will compare against the agent token. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ClientIdentity { + pub(crate) cn: Option, + pub(crate) uri_sans: Vec, + pub(crate) serial_hex: String, + pub(crate) not_after_unix: i64, +} + +impl ClientIdentity { + /// The identity used for logging (and, in Phase 2, matching): the first + /// URI SAN if present — agents are expected to mint `spiffe://`-style + /// URIs — else the Common Name. + pub(crate) fn primary(&self) -> Option<&str> { + self.uri_sans + .first() + .map(String::as_str) + .or(self.cn.as_deref()) + } +} + +impl std::fmt::Display for ClientIdentity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.primary().unwrap_or("unknown")) + } +} + +/// Extract a [`ClientIdentity`] from the peer certificate chain presented +/// during the TLS handshake. `certs[0]` is the end-entity leaf — rustls +/// presents the chain leaf-first — intermediates are ignored. +/// +/// Never panics: an empty slice, a leaf that fails to parse, or a hostile CN/ +/// SAN (see [`sanitize_identity_component`]) all just fall through to `None` +/// pieces rather than a panic. The TLS verifier has already rejected chains +/// that don't verify by the time this runs, so there's nothing to fail closed +/// on here — Phase 1 doesn't enforce, it only reports what it saw. +pub(crate) fn identity_from_peer_certs(certs: &[CertificateDer<'_>]) -> Option { + let leaf = certs.first()?; + let (_, cert) = x509_parser::parse_x509_certificate(leaf.as_ref()).ok()?; + + let cn = cert + .subject() + .iter_common_name() + .next() + .and_then(|attr| attr.as_str().ok()) + .and_then(sanitize_identity_component); + + let uri_sans = cert + .subject_alternative_name() + .ok() + .flatten() + .map(|ext| { + ext.value + .general_names + .iter() + .filter_map(|name| match name { + x509_parser::extensions::GeneralName::URI(uri) => Some(*uri), + _ => None, + }) + .filter_map(sanitize_identity_component) + .collect() + }) + .unwrap_or_default(); + + let serial_hex = hex::encode(cert.raw_serial()); + let not_after_unix = cert.validity().not_after.timestamp(); + + Some(ClientIdentity { + cn, + uri_sans, + serial_hex, + not_after_unix, + }) +} + +/// Validate a single identity component (a CN or a URI SAN) pulled from an +/// otherwise-trusted certificate. The certificate chains to a trust anchor, +/// but its *content* is still attacker-controlled (anyone who can get a cert +/// signed by the configured client CA picks their own CN/SAN) — this becomes +/// a log field and, in Phase 2, a lookup key, so control characters and +/// oversized values are dropped rather than "cleaned up": a component that +/// fails validation contributes nothing rather than a mangled value. +fn sanitize_identity_component(s: &str) -> Option { + if s.is_empty() || s.len() > 253 { + return None; + } + // Printable ASCII only — this also excludes '\n'/'\r' (0x0A/0x0D), which + // fall outside 0x20..=0x7E; '"' and '\\' are inside that range and need + // an explicit check. + if !s.chars().all(|c| matches!(c, '\u{20}'..='\u{7E}')) { + return None; + } + if s.contains('"') || s.contains('\\') { + return None; + } + Some(s.to_string()) +} + +// ── PEM / root store loading ───────────────────────────────────────────── + +/// Resolve a PEM value from a raw string: a value starting with `-----BEGIN` +/// is treated as inline PEM (cloud injects CA/cert/key material this way, +/// from Secrets Manager); anything else is treated as a filesystem path (OSS +/// mounts files). Empty or unset input is `Ok(None)` — the caller decides +/// whether that's fatal. +fn pem_from_value(var_name: &str, value: &str) -> Result> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Ok(None); + } + if trimmed.starts_with("-----BEGIN") { + return Ok(Some(trimmed.to_string())); + } + std::fs::read_to_string(trimmed) + .map(Some) + .with_context(|| format!("reading {var_name} from path {trimmed}")) +} + +/// Same as [`pem_from_value`], reading the raw value from the environment. +#[cfg_attr(not(test), allow(dead_code))] +fn pem_from_env(var_name: &str) -> Result> { + match std::env::var(var_name) { + Ok(value) => pem_from_value(var_name, &value), + Err(_) => Ok(None), + } +} + +/// Parse every certificate out of a PEM bundle. Errors if the PEM is +/// malformed or contains zero certificates — a client CA file that parses to +/// nothing is a misconfiguration, not "no CAs trusted". +fn pem_to_der_certs(pem: &str) -> Result>> { + let mut reader = pem.as_bytes(); + let certs: Vec> = rustls_pemfile::certs(&mut reader) + .collect::>() + .context("parsing PEM certificate(s)")?; + if certs.is_empty() { + bail!("no certificates found in PEM"); + } + Ok(certs) +} + +/// Extract the DER-encoded SubjectPublicKeyInfo (SPKI) from a certificate — +/// "is this the same key", not "is this byte-identical certificate". Used by +/// the MITM-CA-reuse guard in `from_parts`: a certificate can be re-issued or +/// re-encoded (different serial, validity window, or DN) while wrapping the +/// exact same key pair, which a whole-certificate DER comparison would miss. +fn spki_der(cert_der: &CertificateDer<'_>) -> Result> { + let (_, cert) = x509_parser::parse_x509_certificate(cert_der.as_ref()) + .context("parsing certificate to extract its public key")?; + Ok(cert.public_key().raw.to_vec()) +} + +/// Build a [`RootCertStore`] from a PEM bundle of one or more CA certificates. +/// Reused by later phases (e.g. reloading the client CA bundle on rotation). +pub(crate) fn load_client_ca_roots(pem: &str) -> Result> { + let certs = pem_to_der_certs(pem)?; + let mut store = RootCertStore::empty(); + for cert in certs { + store + .add(cert) + .context("adding client CA certificate to root store")?; + } + Ok(Arc::new(store)) +} + +// ── Server config ───────────────────────────────────────────────────────── + +/// Build the mTLS `ServerConfig`: the gateway's own cert/key for the TLS +/// server side, plus `roots` as the trust anchor(s) for verifying client +/// certificates. +fn build_server_config( + cert_pem: &str, + key_pem: &str, + roots: Arc, +) -> Result> { + let cert_chain = pem_to_der_certs(cert_pem).context("parsing GATEWAY_TLS_CERT")?; + + let mut key_reader = key_pem.as_bytes(); + let key: PrivateKeyDer<'static> = rustls_pemfile::private_key(&mut key_reader) + .context("parsing GATEWAY_TLS_KEY")? + .context("no private key found in GATEWAY_TLS_KEY")?; + + // SECURITY: no `.allow_unauthenticated()` on this builder. The builder's + // default policy — reject any handshake that doesn't present a certificate + // verifiable against `roots` — IS the "no cert -> rejected" guarantee this + // whole module exists to provide. Do not add it, even for a "convenience" + // fallback: that would silently reopen the plaintext-equivalent hole this + // port is meant to close. + let verifier = WebPkiClientVerifier::builder(roots) + .build() + .context("building client certificate verifier")?; + + let mut config = ServerConfig::builder() + .with_client_cert_verifier(verifier) + .with_single_cert(cert_chain, key) + .context("building mTLS ServerConfig")?; + + // Force HTTP/1.1 — same rationale as the MITM leaf configs (ca.rs): + // prevent HTTP/2 negotiation via ALPN, since the gateway's connection + // handling assumes HTTP/1.1 semantics (CONNECT interception, upgrades). + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + + // Phase 2 hook: revocation lists would be wired in here via + // `WebPkiClientVerifier::builder(roots).with_crls(...)`. Certificate + // expiry is already validated by the webpki verifier itself — no manual + // `not_after` check is needed (or added) on top of it. + + Ok(Arc::new(config)) +} + +// ── Config ──────────────────────────────────────────────────────────────── + +/// Resolved mTLS listener configuration. `None` (via [`MtlsConfig::from_env`]) +/// means mTLS is off — the gateway runs exactly as it did before this module +/// existed. +#[derive(Debug)] +pub(crate) struct MtlsConfig { + pub(crate) port: u16, + pub(crate) bind: IpAddr, + pub(crate) server_config: Arc, +} + +impl MtlsConfig { + /// Build the mTLS config from already-resolved parts — no environment + /// access, so this is the unit-testable core. `from_env` is a thin + /// wrapper that reads the four env vars and forwards here. + /// + /// `port`/`cert`/`key`/`ca` are the raw `GATEWAY_MTLS_PORT` / + /// `GATEWAY_TLS_CERT` / `GATEWAY_TLS_KEY` / `GATEWAY_CLIENT_CA` values + /// (or `None` if unset) — inline PEM or filesystem path, resolved here via + /// [`pem_from_value`]. `mitm_ca_der` is the gateway's own MITM CA + /// certificate (see `ca.rs`); `plain_port` is the plaintext listener port. + /// + /// `Ok(None)` means mTLS is off (port unset). Every other failure mode — + /// unparseable/zero/colliding port, missing material, unreadable/garbage + /// PEM, or a client CA that IS the MITM CA — is `Err`, and the caller + /// (`main`) must fail closed: never fall back to plaintext-only when mTLS + /// was requested but couldn't be built. + pub(crate) fn from_parts( + port: Option<&str>, + cert: Option<&str>, + key: Option<&str>, + ca: Option<&str>, + mitm_ca_der: &CertificateDer<'static>, + plain_port: u16, + ) -> Result> { + let Some(port_str) = port else { + // GATEWAY_MTLS_PORT unset: mTLS is off. Full backward compatibility. + return Ok(None); + }; + + let port: u16 = port_str.parse().with_context(|| { + format!("GATEWAY_MTLS_PORT {port_str:?} is not a valid port number") + })?; + if port == 0 { + bail!("GATEWAY_MTLS_PORT must not be 0"); + } + if port == plain_port { + bail!( + "GATEWAY_MTLS_PORT ({port}) must differ from the plaintext gateway port ({plain_port})" + ); + } + + let cert_pem = cert + .context("GATEWAY_TLS_CERT is required when GATEWAY_MTLS_PORT is set") + .and_then(|v| pem_from_value("GATEWAY_TLS_CERT", v))? + .context("GATEWAY_TLS_CERT is required when GATEWAY_MTLS_PORT is set")?; + let key_pem = key + .context("GATEWAY_TLS_KEY is required when GATEWAY_MTLS_PORT is set") + .and_then(|v| pem_from_value("GATEWAY_TLS_KEY", v))? + .context("GATEWAY_TLS_KEY is required when GATEWAY_MTLS_PORT is set")?; + let ca_pem = ca + .context("GATEWAY_CLIENT_CA is required when GATEWAY_MTLS_PORT is set") + .and_then(|v| pem_from_value("GATEWAY_CLIENT_CA", v))? + .context("GATEWAY_CLIENT_CA is required when GATEWAY_MTLS_PORT is set")?; + + // SECURITY: reject a client CA bundle that carries the same public + // key as the gateway's own MITM CA. Compared on the DER-encoded + // SubjectPublicKeyInfo (SPKI), not the whole-certificate DER: the real + // risk is the MITM CA's *private key* being host-resident (it signs a + // fresh leaf for every intercepted domain), and a re-issued or + // re-encoded certificate wrapping that SAME key would byte-differ + // from the original cert while remaining exactly as dangerous to + // trust — a whole-cert comparison would miss it. If ANY certificate + // carrying that key were trusted as a client-cert anchor, anyone able + // to mint a MITM leaf could just as easily mint a "valid" client cert + // and impersonate any agent. + let mitm_spki = + spki_der(mitm_ca_der).context("parsing the gateway's own MITM CA certificate")?; + let ca_certs = pem_to_der_certs(&ca_pem).context("parsing GATEWAY_CLIENT_CA")?; + for cert in &ca_certs { + let spki = spki_der(cert) + .context("GATEWAY_CLIENT_CA contains a certificate that failed to parse")?; + if spki == mitm_spki { + bail!( + "GATEWAY_CLIENT_CA must not include a certificate carrying the same public \ + key as the gateway's own MITM CA (its private key lives on this host, so \ + trusting that key as a client anchor would let anyone mint their own \ + client certificate)" + ); + } + } + + let roots = load_client_ca_roots(&ca_pem).context("GATEWAY_CLIENT_CA")?; + let server_config = build_server_config(&cert_pem, &key_pem, roots)?; + + Ok(Some(MtlsConfig { + port, + bind: IpAddr::V4(Ipv4Addr::UNSPECIFIED), + server_config, + })) + } + + /// Read `GATEWAY_MTLS_PORT` / `GATEWAY_TLS_CERT` / `GATEWAY_TLS_KEY` / + /// `GATEWAY_CLIENT_CA` from the environment and forward to [`Self::from_parts`]. + pub(crate) fn from_env( + mitm_ca_der: &CertificateDer<'static>, + plain_port: u16, + ) -> Result> { + let port = std::env::var("GATEWAY_MTLS_PORT").ok(); + let cert = std::env::var("GATEWAY_TLS_CERT").ok(); + let key = std::env::var("GATEWAY_TLS_KEY").ok(); + let ca = std::env::var("GATEWAY_CLIENT_CA").ok(); + Self::from_parts( + port.as_deref(), + cert.as_deref(), + key.as_deref(), + ca.as_deref(), + mitm_ca_der, + plain_port, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use std::net::SocketAddr; + use std::sync::Once; + use std::time::{SystemTime, UNIX_EPOCH}; + + use rcgen::{ + BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, KeyUsagePurpose, + PKCS_ECDSA_P256_SHA256, + }; + use rustls::pki_types::ServerName; + use time::OffsetDateTime; + use tokio::net::{TcpListener, TcpStream}; + use tokio_rustls::{TlsAcceptor, TlsConnector}; + + static INIT_CRYPTO: Once = Once::new(); + + fn ensure_crypto_provider() { + INIT_CRYPTO.call_once(|| { + // Ignore the error: it just means another test module (e.g. + // `ca`) already installed the process-wide default in this same + // test binary — a no-op for our purposes either way, since it's + // the same `ring` provider. + let _ = rustls::crypto::ring::default_provider().install_default(); + }); + } + + // ── sanitize_identity_component ───────────────────────────────────── + + #[test] + fn sanitize_accepts_plain_values() { + assert_eq!( + sanitize_identity_component("agent-42"), + Some("agent-42".to_string()) + ); + assert_eq!( + sanitize_identity_component("spiffe://onecli/agent/42"), + Some("spiffe://onecli/agent/42".to_string()) + ); + } + + #[test] + fn sanitize_drops_empty() { + assert_eq!(sanitize_identity_component(""), None); + } + + #[test] + fn sanitize_drops_oversized() { + let long = "a".repeat(254); + assert_eq!(sanitize_identity_component(&long), None); + // 253 bytes is the boundary — still accepted. + let boundary = "a".repeat(253); + assert!(sanitize_identity_component(&boundary).is_some()); + } + + #[test] + fn sanitize_drops_newline_and_cr() { + assert_eq!(sanitize_identity_component("agent\n42"), None); + assert_eq!(sanitize_identity_component("agent\r42"), None); + } + + #[test] + fn sanitize_drops_quote_and_backslash() { + assert_eq!(sanitize_identity_component("agent\"42"), None); + assert_eq!(sanitize_identity_component("agent\\42"), None); + } + + #[test] + fn sanitize_drops_non_ascii() { + assert_eq!(sanitize_identity_component("agenté"), None); + } + + // ── ClientIdentity::primary ────────────────────────────────────────── + + #[test] + fn primary_prefers_uri_san_over_cn() { + let id = ClientIdentity { + cn: Some("fallback-cn".to_string()), + uri_sans: vec!["spiffe://onecli/agent/1".to_string()], + serial_hex: "ab".to_string(), + not_after_unix: 0, + }; + assert_eq!(id.primary(), Some("spiffe://onecli/agent/1")); + } + + #[test] + fn primary_falls_back_to_cn() { + let id = ClientIdentity { + cn: Some("cn-only".to_string()), + uri_sans: vec![], + serial_hex: "ab".to_string(), + not_after_unix: 0, + }; + assert_eq!(id.primary(), Some("cn-only")); + } + + #[test] + fn primary_none_when_both_missing() { + let id = ClientIdentity { + cn: None, + uri_sans: vec![], + serial_hex: "ab".to_string(), + not_after_unix: 0, + }; + assert_eq!(id.primary(), None); + } + + #[test] + fn display_uses_primary() { + let id = ClientIdentity { + cn: Some("cn-only".to_string()), + uri_sans: vec![], + serial_hex: "ab".to_string(), + not_after_unix: 0, + }; + assert_eq!(id.to_string(), "cn-only"); + } + + // ── identity_from_peer_certs: empty/malformed input never panics ──── + + #[test] + fn identity_from_empty_slice_is_none() { + assert_eq!(identity_from_peer_certs(&[]), None); + } + + #[test] + fn identity_from_garbage_der_is_none() { + let garbage = CertificateDer::from(vec![0u8, 1, 2, 3, 4]); + assert_eq!( + identity_from_peer_certs(std::slice::from_ref(&garbage)), + None + ); + } + + // ── test PKI helper ─────────────────────────────────────────────────── + + /// A minimal CA + leaf-signing helper built on rcgen, mirroring the + /// pattern in `ca.rs`'s own test module. + struct TestCa { + cert: rcgen::Certificate, + key: KeyPair, + der: CertificateDer<'static>, + } + + fn new_test_ca(cn: &str) -> TestCa { + let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).expect("CA key"); + let mut params = CertificateParams::default(); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params.distinguished_name.push(DnType::CommonName, cn); + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + params.not_before = OffsetDateTime::now_utc() - time::Duration::hours(1); + params.not_after = OffsetDateTime::now_utc() + time::Duration::days(3650); + let cert = params.self_signed(&key).expect("self-sign CA"); + let der = cert.der().clone(); + TestCa { cert, key, der } + } + + /// Sign a client leaf under `ca`, valid `[not_before_h, not_after_h]` hours + /// from now, with the given CN and URI SANs. Returns (cert_pem, key_pem). + fn sign_client_leaf( + ca: &TestCa, + cn: Option<&str>, + uri_sans: &[&str], + not_before_h: i64, + not_after_h: i64, + ) -> (String, String) { + let leaf_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).expect("leaf key"); + let mut params = CertificateParams::default(); + // `CertificateParams::new(strings)` only ever infers IP or DNS SANs — + // a "spiffe://..."-shaped string comes out as a (nonsensical) DNS + // name, not a URI SAN. Push `SanType::URI` directly instead. + params.subject_alt_names = uri_sans + .iter() + .map(|s| { + rcgen::SanType::URI( + rcgen::Ia5String::try_from(s.to_string()).expect("valid IA5 URI"), + ) + }) + .collect(); + if let Some(cn) = cn { + params.distinguished_name.push(DnType::CommonName, cn); + } + params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; + params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ClientAuth]; + params.not_before = OffsetDateTime::now_utc() + time::Duration::hours(not_before_h); + params.not_after = OffsetDateTime::now_utc() + time::Duration::hours(not_after_h); + let leaf_cert = params + .signed_by(&leaf_key, &ca.cert, &ca.key) + .expect("sign leaf"); + (leaf_cert.pem(), leaf_key.serialize_pem()) + } + + /// Self-signed "server" cert for `localhost`, used as the mTLS listener's + /// own identity in handshake tests. The test client trusts it directly + /// (it's its own root), sidestepping the need for a fake server verifier. + fn self_signed_server_cert() -> (String, String, CertificateDer<'static>) { + let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).expect("server key"); + let mut params = CertificateParams::new(vec!["localhost".to_string()]).expect("params"); + params + .distinguished_name + .push(DnType::CommonName, "localhost"); + params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth]; + params.not_before = OffsetDateTime::now_utc() - time::Duration::hours(1); + params.not_after = OffsetDateTime::now_utc() + time::Duration::days(1); + let cert = params.self_signed(&key).expect("self-sign server cert"); + let der = cert.der().clone(); + (cert.pem(), key.serialize_pem(), der) + } + + /// Build a server config AND return the matching server cert DER — the + /// two must come from the same `self_signed_server_cert()` call, since + /// the test client trusts that DER directly as its only root. + fn test_server_setup(trusted_ca_pem: &str) -> (Arc, CertificateDer<'static>) { + let (server_cert_pem, server_key_pem, server_der) = self_signed_server_cert(); + let roots = load_client_ca_roots(trusted_ca_pem).expect("roots"); + let config = + build_server_config(&server_cert_pem, &server_key_pem, roots).expect("server config"); + (config, server_der) + } + + fn test_client_config( + server_der: &CertificateDer<'static>, + client_cert_pem: Option<&str>, + client_key_pem: Option<&str>, + ) -> Arc { + let mut roots = RootCertStore::empty(); + roots.add(server_der.clone()).expect("trust server cert"); + + let builder = rustls::ClientConfig::builder().with_root_certificates(roots); + let config = match (client_cert_pem, client_key_pem) { + (Some(cert_pem), Some(key_pem)) => { + let chain = pem_to_der_certs(cert_pem).expect("client cert chain"); + let mut key_reader = key_pem.as_bytes(); + let key = rustls_pemfile::private_key(&mut key_reader) + .expect("parse client key") + .expect("client key present"); + builder + .with_client_auth_cert(chain, key) + .expect("client auth cert") + } + _ => builder.with_no_client_auth(), + }; + Arc::new(config) + } + + /// Run one TLS handshake end to end over a real loopback socket (same + /// pattern as the plain-TCP tests in `gateway.rs`/`ca.rs`, just over TLS). + /// Returns the server-side accept result — the thing under test — and + /// discards the client-side result beyond confirming it also failed when + /// the server did (a rejected handshake fails both sides). + async fn attempt_handshake( + server_config: Arc, + client_config: Arc, + ) -> std::io::Result> { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr: SocketAddr = listener.local_addr().expect("local addr"); + + let server = async move { + let (stream, _) = listener.accept().await?; + TlsAcceptor::from(server_config).accept(stream).await + }; + let client = async move { + let stream = TcpStream::connect(addr).await?; + let name = ServerName::try_from("localhost").expect("server name"); + TlsConnector::from(client_config) + .connect(name, stream) + .await + }; + + let (server_result, _client_result) = tokio::join!(server, client); + server_result + } + + fn err_debug_contains(err: &std::io::Error, needle: &str) -> bool { + format!("{err:?}").contains(needle) + } + + // ── Handshake behavior ──────────────────────────────────────────────── + + #[tokio::test] + async fn handshake_rejects_missing_client_cert() { + ensure_crypto_provider(); + let ca = new_test_ca("Test Client CA"); + let ca_pem = ca.cert.pem(); + let (server_config, server_der) = test_server_setup(&ca_pem); + let client_config = test_client_config(&server_der, None, None); + + let result = attempt_handshake(server_config, client_config).await; + let err = result.expect_err("handshake without a client cert must fail"); + assert!( + err_debug_contains(&err, "NoCertificatesPresented") + || err_debug_contains(&err, "CertificateRequired"), + "unexpected error: {err:?}" + ); + } + + #[tokio::test] + async fn handshake_rejects_wrong_ca() { + ensure_crypto_provider(); + let trusted_ca = new_test_ca("Trusted Client CA"); + let other_ca = new_test_ca("Some Other CA"); + let (cert_pem, key_pem) = sign_client_leaf(&other_ca, Some("agent-1"), &[], -1, 24); + + let (server_config, server_der) = test_server_setup(&trusted_ca.cert.pem()); + let client_config = test_client_config(&server_der, Some(&cert_pem), Some(&key_pem)); + + let result = attempt_handshake(server_config, client_config).await; + let err = result.expect_err("handshake signed by an untrusted CA must fail"); + assert!( + err_debug_contains(&err, "UnknownIssuer"), + "unexpected error: {err:?}" + ); + } + + #[tokio::test] + async fn handshake_rejects_expired_cert() { + ensure_crypto_provider(); + let ca = new_test_ca("Trusted Client CA"); + // Valid window entirely in the past. + let (cert_pem, key_pem) = sign_client_leaf(&ca, Some("agent-1"), &[], -48, -24); + + let (server_config, server_der) = test_server_setup(&ca.cert.pem()); + let client_config = test_client_config(&server_der, Some(&cert_pem), Some(&key_pem)); + + let result = attempt_handshake(server_config, client_config).await; + let err = result.expect_err("handshake with an expired client cert must fail"); + assert!( + err_debug_contains(&err, "Expired"), + "unexpected error: {err:?}" + ); + } + + #[tokio::test] + async fn handshake_accepts_valid_cert_with_uri_san() { + ensure_crypto_provider(); + let ca = new_test_ca("Trusted Client CA"); + let (cert_pem, key_pem) = sign_client_leaf( + &ca, + Some("fallback-cn"), + &["spiffe://onecli/agent/1"], + -1, + 24, + ); + + let (server_config, server_der) = test_server_setup(&ca.cert.pem()); + let client_config = test_client_config(&server_der, Some(&cert_pem), Some(&key_pem)); + + let mut tls_stream = attempt_handshake(server_config, client_config) + .await + .expect("valid client cert must be accepted"); + + let identity = tls_stream + .get_ref() + .1 + .peer_certificates() + .and_then(identity_from_peer_certs) + .expect("identity must be extracted"); + assert_eq!(identity.primary(), Some("spiffe://onecli/agent/1")); + assert_eq!(identity.cn.as_deref(), Some("fallback-cn")); + + // Drain so the client side's write half doesn't hang the test. + use tokio::io::AsyncWriteExt; + let _ = tls_stream.shutdown().await; + } + + #[tokio::test] + async fn handshake_accepts_valid_cert_cn_only() { + ensure_crypto_provider(); + let ca = new_test_ca("Trusted Client CA"); + let (cert_pem, key_pem) = sign_client_leaf(&ca, Some("cn-only-agent"), &[], -1, 24); + + let (server_config, server_der) = test_server_setup(&ca.cert.pem()); + let client_config = test_client_config(&server_der, Some(&cert_pem), Some(&key_pem)); + + let mut tls_stream = attempt_handshake(server_config, client_config) + .await + .expect("valid client cert must be accepted"); + + let identity = tls_stream + .get_ref() + .1 + .peer_certificates() + .and_then(identity_from_peer_certs) + .expect("identity must be extracted"); + assert_eq!(identity.primary(), Some("cn-only-agent")); + assert!(identity.uri_sans.is_empty()); + + use tokio::io::AsyncWriteExt; + let _ = tls_stream.shutdown().await; + } + + #[tokio::test] + async fn server_config_pins_http11_alpn() { + ensure_crypto_provider(); + let ca = new_test_ca("Trusted Client CA"); + let (config, _server_der) = test_server_setup(&ca.cert.pem()); + assert_eq!(config.alpn_protocols, vec![b"http/1.1".to_vec()]); + } + + // ── from_parts: no env access ──────────────────────────────────────── + + fn dummy_mitm_ca_der() -> CertificateDer<'static> { + new_test_ca("Dummy MITM CA").der + } + + #[test] + fn from_parts_port_none_is_off() { + ensure_crypto_provider(); + let mitm_der = dummy_mitm_ca_der(); + let result = MtlsConfig::from_parts(None, None, None, None, &mitm_der, 10255).unwrap(); + assert!(result.is_none()); + } + + /// Stands in for "this field is present" in `from_parts` tests that only + /// care about a *different* field. Must start with `-----BEGIN` so + /// `pem_from_value` takes the inline-PEM branch rather than trying (and + /// failing) to read it as a filesystem path — its content is never + /// actually parsed in these tests, since the function under test returns + /// before reaching that point. + const PRESENT_PLACEHOLDER_PEM: &str = + "-----BEGIN CERTIFICATE-----\nplaceholder\n-----END CERTIFICATE-----\n"; + + #[test] + fn from_parts_missing_cert_errs_naming_it() { + ensure_crypto_provider(); + let mitm_der = dummy_mitm_ca_der(); + let err = MtlsConfig::from_parts( + Some("10256"), + None, + Some("key"), + Some("ca"), + &mitm_der, + 10255, + ) + .unwrap_err(); + assert!(format!("{err:#}").contains("GATEWAY_TLS_CERT")); + } + + #[test] + fn from_parts_missing_key_errs_naming_it() { + ensure_crypto_provider(); + let mitm_der = dummy_mitm_ca_der(); + let err = MtlsConfig::from_parts( + Some("10256"), + Some(PRESENT_PLACEHOLDER_PEM), + None, + Some(PRESENT_PLACEHOLDER_PEM), + &mitm_der, + 10255, + ) + .unwrap_err(); + assert!(format!("{err:#}").contains("GATEWAY_TLS_KEY")); + } + + #[test] + fn from_parts_missing_ca_errs_naming_it() { + ensure_crypto_provider(); + let mitm_der = dummy_mitm_ca_der(); + let err = MtlsConfig::from_parts( + Some("10256"), + Some(PRESENT_PLACEHOLDER_PEM), + Some(PRESENT_PLACEHOLDER_PEM), + None, + &mitm_der, + 10255, + ) + .unwrap_err(); + assert!(format!("{err:#}").contains("GATEWAY_CLIENT_CA")); + } + + #[test] + fn from_parts_zero_port_errs() { + ensure_crypto_provider(); + let mitm_der = dummy_mitm_ca_der(); + let err = + MtlsConfig::from_parts(Some("0"), Some("c"), Some("k"), Some("a"), &mitm_der, 10255) + .unwrap_err(); + assert!(format!("{err:#}").contains("must not be 0")); + } + + #[test] + fn from_parts_garbage_port_errs() { + ensure_crypto_provider(); + let mitm_der = dummy_mitm_ca_der(); + let err = MtlsConfig::from_parts( + Some("not-a-port"), + Some("c"), + Some("k"), + Some("a"), + &mitm_der, + 10255, + ) + .unwrap_err(); + assert!(format!("{err:#}").contains("not a valid port")); + } + + #[test] + fn from_parts_port_equals_plain_port_errs() { + ensure_crypto_provider(); + let mitm_der = dummy_mitm_ca_der(); + let err = MtlsConfig::from_parts( + Some("10255"), + Some("c"), + Some("k"), + Some("a"), + &mitm_der, + 10255, + ) + .unwrap_err(); + assert!(format!("{err:#}").contains("must differ")); + } + + #[test] + fn from_parts_loads_inline_pem_and_path_forms() { + ensure_crypto_provider(); + let ca = new_test_ca("Trusted Client CA"); + let (server_cert_pem, server_key_pem, _) = self_signed_server_cert(); + let mitm_der = dummy_mitm_ca_der(); + let ca_pem = ca.cert.pem(); + + // Inline PEM form for all three. + let result = MtlsConfig::from_parts( + Some("10256"), + Some(&server_cert_pem), + Some(&server_key_pem), + Some(&ca_pem), + &mitm_der, + 10255, + ) + .expect("inline PEM must load"); + assert!(result.is_some()); + + // Path form: write each to a tempfile and pass the path. + let dir = tempfile::tempdir().expect("tempdir"); + let cert_path = dir.path().join("cert.pem"); + let key_path = dir.path().join("key.pem"); + let ca_path = dir.path().join("ca.pem"); + std::fs::write(&cert_path, &server_cert_pem).expect("write cert"); + std::fs::write(&key_path, &server_key_pem).expect("write key"); + std::fs::write(&ca_path, &ca_pem).expect("write ca"); + + let result = MtlsConfig::from_parts( + Some("10256"), + Some(cert_path.to_str().unwrap()), + Some(key_path.to_str().unwrap()), + Some(ca_path.to_str().unwrap()), + &mitm_der, + 10255, + ) + .expect("path form must load"); + assert!(result.is_some()); + } + + #[test] + fn from_parts_bad_path_errs() { + ensure_crypto_provider(); + let mitm_der = dummy_mitm_ca_der(); + let err = MtlsConfig::from_parts( + Some("10256"), + Some("/nonexistent/path/cert.pem"), + Some("/nonexistent/path/key.pem"), + Some("/nonexistent/path/ca.pem"), + &mitm_der, + 10255, + ) + .unwrap_err(); + assert!(format!("{err:#}").contains("GATEWAY_TLS_CERT")); + } + + #[test] + fn from_parts_garbage_pem_errs() { + ensure_crypto_provider(); + let mitm_der = dummy_mitm_ca_der(); + let (server_cert_pem, server_key_pem, _) = self_signed_server_cert(); + let err = MtlsConfig::from_parts( + Some("10256"), + Some(&server_cert_pem), + Some(&server_key_pem), + Some("-----BEGIN CERTIFICATE-----\nbm90IGEgY2VydA==\n-----END CERTIFICATE-----\n"), + &mitm_der, + 10255, + ) + .unwrap_err(); + assert!(format!("{err:#}").contains("GATEWAY_CLIENT_CA")); + } + + #[test] + fn from_parts_empty_client_ca_errs() { + ensure_crypto_provider(); + let mitm_der = dummy_mitm_ca_der(); + let (server_cert_pem, server_key_pem, _) = self_signed_server_cert(); + // Empty value resolves to None via pem_from_value, which is then the + // "missing" case for a mandatory var. + let err = MtlsConfig::from_parts( + Some("10256"), + Some(&server_cert_pem), + Some(&server_key_pem), + Some(""), + &mitm_der, + 10255, + ) + .unwrap_err(); + assert!(format!("{err:#}").contains("GATEWAY_CLIENT_CA")); + } + + #[test] + fn from_parts_rejects_client_ca_matching_mitm_ca() { + ensure_crypto_provider(); + let mitm_ca = new_test_ca("Gateway MITM CA"); + let (server_cert_pem, server_key_pem, _) = self_signed_server_cert(); + + // GATEWAY_CLIENT_CA is (accidentally) the same cert as the MITM CA. + let err = MtlsConfig::from_parts( + Some("10256"), + Some(&server_cert_pem), + Some(&server_key_pem), + Some(&mitm_ca.cert.pem()), + &mitm_ca.der, + 10255, + ) + .unwrap_err(); + assert!(format!("{err:#}").contains("MITM CA")); + } + + /// FIX 3: the guard compares public keys (SPKI), not whole-certificate + /// DER — a certificate carrying the SAME key as the MITM CA must still be + /// rejected even though it's a byte-different certificate (different CN, + /// serial, and validity window — e.g. a re-issued or re-encoded cert). + #[test] + fn from_parts_rejects_client_ca_with_same_public_key_as_mitm_ca_even_if_cert_differs() { + ensure_crypto_provider(); + + let mitm_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).expect("mitm key"); + let mut mitm_params = CertificateParams::default(); + mitm_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + mitm_params + .distinguished_name + .push(DnType::CommonName, "Gateway MITM CA"); + mitm_params.not_before = OffsetDateTime::now_utc() - time::Duration::hours(1); + mitm_params.not_after = OffsetDateTime::now_utc() + time::Duration::days(3650); + let mitm_cert = mitm_params.self_signed(&mitm_key).expect("self-sign mitm"); + let mitm_der = mitm_cert.der().clone(); + + // A DIFFERENT certificate — different CN, serial, and validity window + // (as a re-issued cert would be) — but signed with the SAME key pair. + let mut reissued_params = CertificateParams::default(); + reissued_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + reissued_params + .distinguished_name + .push(DnType::CommonName, "Totally Unrelated Client CA"); + reissued_params.not_before = OffsetDateTime::now_utc() - time::Duration::hours(2); + reissued_params.not_after = OffsetDateTime::now_utc() + time::Duration::days(30); + let reissued_cert = reissued_params + .self_signed(&mitm_key) + .expect("self-sign reissued cert with the same key"); + + // Sanity check: the two certs must NOT be byte-identical — otherwise + // this test would exercise the same path as the whole-DER case above + // and prove nothing new. + assert_ne!(reissued_cert.der().as_ref(), mitm_der.as_ref()); + + let (server_cert_pem, server_key_pem, _) = self_signed_server_cert(); + let err = MtlsConfig::from_parts( + Some("10256"), + Some(&server_cert_pem), + Some(&server_key_pem), + Some(&reissued_cert.pem()), + &mitm_der, + 10255, + ) + .unwrap_err(); + assert!(format!("{err:#}").contains("GATEWAY_CLIENT_CA")); + assert!(format!("{err:#}").contains("public")); + } + + // ── pem_from_env / pem_from_value ──────────────────────────────────── + + #[test] + fn pem_from_value_empty_is_none() { + assert_eq!(pem_from_value("X", "").unwrap(), None); + assert_eq!(pem_from_value("X", " ").unwrap(), None); + } + + #[test] + fn pem_from_value_inline_pem_passthrough() { + // Leading/trailing whitespace around the value is trimmed (matching + // ca.rs's `load_from_pem`), so assert against the trimmed form. + let pem = "-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----\n"; + assert_eq!( + pem_from_value("X", pem).unwrap(), + Some(pem.trim().to_string()) + ); + } + + #[test] + fn pem_from_value_reads_path() { + let mut file = tempfile::NamedTempFile::new().expect("tempfile"); + write!(file, "file-contents").expect("write"); + let path = file.path().to_str().unwrap(); + assert_eq!( + pem_from_value("X", path).unwrap(), + Some("file-contents".to_string()) + ); + } + + #[test] + fn pem_from_value_bad_path_errs() { + let err = pem_from_value("GATEWAY_TLS_CERT", "/no/such/file.pem").unwrap_err(); + assert!(format!("{err:#}").contains("GATEWAY_TLS_CERT")); + assert!(format!("{err:#}").contains("/no/such/file.pem")); + } + + #[test] + fn pem_from_env_unset_is_none() { + // A var name essentially guaranteed not to be set. + assert_eq!( + pem_from_env("GATEWAY_CA_TEST_DOES_NOT_EXIST_XYZ").unwrap(), + None + ); + } + + // ── load_client_ca_roots ────────────────────────────────────────────── + + #[test] + fn load_client_ca_roots_empty_pem_errs() { + assert!(load_client_ca_roots("").is_err()); + } + + #[test] + fn load_client_ca_roots_garbage_errs() { + assert!(load_client_ca_roots("not pem at all").is_err()); + } + + #[test] + fn load_client_ca_roots_valid_pem_ok() { + let ca = new_test_ca("Trusted Client CA"); + assert!(load_client_ca_roots(&ca.cert.pem()).is_ok()); + } + + // Sanity: not_after_unix reflects the certificate's actual expiry, so a + // "certificate expires in ~1 day" leaf really does report a timestamp + // roughly a day in the future (the verifier — not this field — is what + // rejects expired certs; this just confirms the value is meaningful for + // Phase 2 to eventually build on). + #[tokio::test] + async fn identity_not_after_matches_leaf_validity() { + ensure_crypto_provider(); + let ca = new_test_ca("Trusted Client CA"); + let (cert_pem, key_pem) = sign_client_leaf(&ca, Some("agent-1"), &[], -1, 24); + let (server_config, server_der) = test_server_setup(&ca.cert.pem()); + let client_config = test_client_config(&server_der, Some(&cert_pem), Some(&key_pem)); + + let mut tls_stream = attempt_handshake(server_config, client_config) + .await + .expect("valid cert accepted"); + let identity = tls_stream + .get_ref() + .1 + .peer_certificates() + .and_then(identity_from_peer_certs) + .expect("identity extracted"); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + // Leaf expires ~24h from now; allow generous slack for test runtime. + assert!(identity.not_after_unix > now); + assert!(identity.not_after_unix < now + 25 * 3600); + + use tokio::io::AsyncWriteExt; + let _ = tls_stream.shutdown().await; + } +} diff --git a/apps/gateway/src/gateway.rs b/apps/gateway/src/gateway.rs index d4d83fd3..e5bbf76e 100644 --- a/apps/gateway/src/gateway.rs +++ b/apps/gateway/src/gateway.rs @@ -36,8 +36,9 @@ mod transforms; mod tunnel; mod websocket; -use std::net::SocketAddr; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::sync::Arc; +use std::time::Duration; use anyhow::{Context, Result}; use axum::extract::State; @@ -47,8 +48,10 @@ use hyper::server::conn::http1; use hyper::service::service_fn; use hyper::{Method, Request, Response, StatusCode}; use hyper_util::rt::TokioIo; -use tokio::net::{TcpListener, TcpStream}; -use tokio_rustls::TlsConnector; +use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::net::TcpListener; +use tokio::time::timeout; +use tokio_rustls::{TlsAcceptor, TlsConnector}; use tower::ServiceExt; use tower_http::cors::CorsLayer; use tracing::{debug, info, info_span, warn, Instrument}; @@ -57,20 +60,29 @@ use crate::approval::{ApprovalDecision, ApprovalStore, APPROVAL_TIMEOUT_SECS}; use crate::auth::AuthUser; use crate::ca::CertificateAuthority; use crate::cache::CacheStore; +use crate::client_ca::{self, ClientIdentity, MtlsConfig}; use crate::connect::{self, AppConnectionResult, ConnectError, PolicyEngine}; use crate::db; use crate::inject; use crate::vault; -/// Pause before retrying a failed `accept`, so a persistent error (a truly -/// exhausted fd table) cannot spin the loop at full tilt. -const ACCEPT_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(100); +/// Cap the client TLS handshake on the mTLS listener so a stalled or hostile +/// ClientHello can't hold a connection task (and, in effect, the socket) open +/// indefinitely. +const TLS_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); + +/// Backoff after a recoverable `accept()` error (e.g. EMFILE under fd +/// pressure) before retrying. Both listeners run under the same +/// `tokio::try_join!` in `run()`, so an unhandled error from one accept loop +/// would cancel the other — this keeps a transient error confined to its own +/// listener instead. +const ACCEPT_RETRY_DELAY: Duration = Duration::from_millis(100); // ── GatewayState ─────────────────────────────────────────────────────── /// Context for a proxied request, resolved at CONNECT time. /// Wrapped in `Arc` and shared across all requests within a MITM session. -#[derive(Debug)] +#[derive(Debug, Default)] pub(crate) struct ProxyContext { pub project_id: Option, pub organization_id: Option, @@ -78,6 +90,13 @@ pub(crate) struct ProxyContext { pub agent_name: Option, pub agent_identifier: Option, pub agent_token: Option, + /// Identity extracted from the client's mTLS certificate, when the + /// connection came in on the mTLS listener. Phase 1 only threads and logs + /// this (the log statements read the identity before it's moved in here) + /// — it is never compared against `agent_token`; Phase 2 is the first + /// reader of the field itself, hence the lint allowance below. + #[allow(dead_code)] + pub client_identity: Option>, } /// Shared state for the gateway, passed to all request handlers. @@ -113,6 +132,13 @@ pub(crate) struct GatewayState { pub struct GatewayServer { state: GatewayState, port: u16, + /// `None` when `GATEWAY_MTLS_PORT` is unset — the gateway then runs + /// exactly as it did before mTLS support existed. + mtls: Option, + /// Bind address for the plaintext listener. Defaults to `0.0.0.0` + /// (`GATEWAY_PLAIN_BIND`) — see the warning in `new()` about what + /// narrowing it costs when mTLS is also enabled. + plain_bind: IpAddr, } /// Build the HTTP client used for upstream requests. @@ -247,6 +273,35 @@ fn parse_skip_verify_hosts() -> Vec { .collect() } +/// Parse an already-read `GATEWAY_PLAIN_BIND` value (`None` when the var is +/// unset) into a bind address, defaulting to `0.0.0.0` (unrestricted — +/// today's behavior, unchanged unless the operator opts into narrowing it). +/// +/// Fails closed: unset/empty stays the default, but a SET-and-unparseable +/// value (a typo like `127.0.0.q`, or `localhost`, which isn't an IP literal) +/// is an `Err`, not a silent fallback to the wide-open default. This is the +/// one operator knob for restricting the always-open plaintext listener, so +/// silently widening it on a typo would defeat the whole point of the knob. +/// +/// No env access — that's `parse_plain_bind`'s job — so this is directly +/// unit-testable, mirroring the `from_parts`/`from_env` split in `client_ca.rs`. +fn parse_plain_bind_value(value: Option<&str>) -> Result { + match value { + None => Ok(IpAddr::V4(Ipv4Addr::UNSPECIFIED)), + Some(s) if s.trim().is_empty() => Ok(IpAddr::V4(Ipv4Addr::UNSPECIFIED)), + Some(s) => s + .trim() + .parse() + .with_context(|| format!("GATEWAY_PLAIN_BIND {s:?} is not a valid IP address")), + } +} + +/// Read `GATEWAY_PLAIN_BIND` from the environment and parse it via +/// [`parse_plain_bind_value`]. +fn parse_plain_bind() -> Result { + parse_plain_bind_value(std::env::var("GATEWAY_PLAIN_BIND").ok().as_deref()) +} + /// Returns true if `host` matches any pattern in `patterns`. /// /// - `*.example.com` matches `sub.example.com` but NOT `example.com` itself. @@ -266,6 +321,7 @@ fn host_matches_skip_verify(host: &str, patterns: &[String]) -> bool { } impl GatewayServer { + #[allow(clippy::too_many_arguments)] pub fn new( ca: CertificateAuthority, port: u16, @@ -273,7 +329,8 @@ impl GatewayServer { vault_service: Arc, cache: Arc, approval_store: Arc, - ) -> Self { + mtls: Option, + ) -> Result { let global_skip = std::env::var("GATEWAY_DANGER_ACCEPT_INVALID_CERTS").is_ok(); let skip_verify_hosts = Arc::new(parse_skip_verify_hosts()); @@ -283,6 +340,17 @@ impl GatewayServer { info!(hosts = ?skip_verify_hosts.as_ref(), "TLS verification disabled for matched hosts (GATEWAY_SKIP_VERIFY_HOSTS)"); } + let plain_bind = parse_plain_bind()?; + if mtls.is_some() && plain_bind.is_unspecified() { + warn!( + "GATEWAY_MTLS_PORT is set but the plaintext listener is still bound to \ + 0.0.0.0 — anyone who can reach that port bypasses certificate \ + authentication entirely. Set GATEWAY_PLAIN_BIND=127.0.0.1 to restrict it, \ + but note that loopback also breaks Docker-published browser -> gateway \ + vault/approval/cache calls, which arrive on the plaintext listener." + ); + } + let state = GatewayState { ca: Arc::new(ca), http_client: build_http_client(global_skip), @@ -296,23 +364,18 @@ impl GatewayServer { approval_store, }; - Self { state, port } + Ok(Self { + state, + port, + mtls, + plain_bind, + }) } - /// Start the gateway TCP listener. Runs forever. - pub async fn run(&self) -> Result<()> { - let addr = SocketAddr::from(([0, 0, 0, 0], self.port)); - let listener = TcpListener::bind(addr) - .await - .context("binding TCP listener")?; - - // Report what we actually bound rather than what we asked for: with - // `--port 0` the OS assigns the port, and the requested address would - // report `:0` — leaving no way to discover where the gateway is listening. - let bound_addr = listener.local_addr().context("reading bound address")?; - - info!(addr = %bound_addr, "listening for connections"); - + /// Build the Axum router for non-CONNECT routes (healthz, vault API, + /// approvals, org routes, ...). Shared by both listeners — the plaintext + /// one and, when configured, the mTLS one. + fn build_router(&self) -> Router { // CORS configuration for browser → gateway requests. // credentials: true requires explicit headers/methods (not wildcard *). let cors_layer = CorsLayer::new() @@ -420,53 +483,169 @@ impl GatewayServer { // Org-scoped routes are mounted via an edition-swapped seam // (`ee/org_routes.rs` for cloud + onprem, an identity stub for OSS — see // `main.rs`), so the org handler never reaches the OSS build. - let axum_router = crate::org_routes::mount(axum_router) + crate::org_routes::mount(axum_router) .layer(cors_layer) .fallback(fallback) - .with_state(self.state.clone()); - - let mut shutdown_signal = crate::shutdown::subscribe(); - - loop { - let (stream, peer_addr) = tokio::select! { - accepted = listener.accept() => match accepted { - Ok(conn) => conn, - Err(e) => { - // Accept failures are almost always transient and - // self-healing (EMFILE clears as connections close, - // ECONNABORTED is a client that gave up mid-handshake). - // Propagating one would tear down every healthy - // connection this proxy is carrying. - warn!(error = %e, "accept failed; retrying"); - tokio::time::sleep(ACCEPT_RETRY_DELAY).await; - continue; - } - }, - _ = shutdown_signal.wait() => break, - }; + .with_state(self.state.clone()) + } - let state = self.state.clone(); - let router = axum_router.clone(); - let guard = crate::shutdown::task_guard(); + /// Start the gateway's TCP listener(s). Runs forever. + /// + /// Always binds the plaintext listener. When mTLS is configured, ALSO + /// binds the mTLS listener before either accept loop starts — so a bind + /// failure on either port aborts startup instead of leaving one listener + /// silently running without the other — then drives both accept loops + /// concurrently for the life of the process. + pub async fn run(&self) -> Result<()> { + let plain_addr = SocketAddr::new(self.plain_bind, self.port); + let plain_listener = TcpListener::bind(plain_addr) + .await + .context("binding plaintext TCP listener")?; + // Report what we actually bound rather than what we asked for: with + // `--port 0` the OS assigns the port, and the requested address would + // report `:0` — leaving no way to discover where the gateway is listening. + let plain_bound = plain_listener + .local_addr() + .context("reading bound plaintext address")?; + info!(addr = %plain_bound, "listening for plaintext connections"); + + let mtls_listener = match &self.mtls { + Some(mtls) => { + let mtls_addr = SocketAddr::new(mtls.bind, mtls.port); + let listener = TcpListener::bind(mtls_addr) + .await + .context("binding mTLS TCP listener")?; + let mtls_bound = listener + .local_addr() + .context("reading bound mTLS address")?; + info!(addr = %mtls_bound, "listening for mTLS connections"); + Some((listener, TlsAcceptor::from(Arc::clone(&mtls.server_config)))) + } + None => None, + }; - tokio::spawn(async move { - let _guard = guard; - if let Err(e) = handle_connection(stream, peer_addr, state, router).await { - warn!(peer = %peer_addr, error = ?e, "connection error"); - } - }); + let router = self.build_router(); + let plain_loop = accept_loop( + plain_listener, + "plaintext", + router.clone(), + self.state.clone(), + None, + ); + + match mtls_listener { + Some((listener, acceptor)) => { + let mtls_loop = + accept_loop(listener, "mTLS", router, self.state.clone(), Some(acceptor)); + tokio::try_join!(plain_loop, mtls_loop)?; + } + None => plain_loop.await?, } - // Closing the port is what stops new work: anything that connects from - // here on is refused rather than accepted into a process on its way - // out. Dropped explicitly rather than at the end of the scope so the - // port is provably shut before the line below claims it is. - drop(listener); - info!("listener closed — draining connections"); Ok(()) } } +/// Accept connections from `listener` forever, spawning a task per connection. +/// `name` labels this listener in logs (`"plaintext"` or `"mTLS"`) so the two +/// concurrent accept loops are distinguishable. +/// +/// Stops accepting NEW connections once a shutdown signal arrives (breaking +/// out of the loop below) — in-flight connections are tracked via a shutdown +/// task guard and drained by `main`'s shutdown sequence, not by this loop. +/// +/// When `tls` is set, the TLS handshake happens *inside* the spawned task — +/// never in this loop — so one slow or hostile `ClientHello` can only stall +/// its own connection, not every other pending accept. +async fn accept_loop( + listener: TcpListener, + name: &'static str, + router: Router, + state: GatewayState, + tls: Option, +) -> Result<()> { + let mut shutdown_signal = crate::shutdown::subscribe(); + + loop { + let (stream, peer_addr) = tokio::select! { + accepted = listener.accept() => match accepted { + Ok(pair) => pair, + Err(e) => { + // Log-and-continue: a recoverable accept() error (EMFILE + // under fd pressure, ECONNABORTED from a client that gave + // up mid-handshake) must not propagate. Both listeners' + // loops are driven by the same `tokio::try_join!` in + // `run()`, so an `Err` here would cancel the OTHER loop + // too, taking down a perfectly healthy listener over a + // transient blip on this one. + warn!(listener = name, error = %e, "accept() failed, retrying"); + tokio::time::sleep(ACCEPT_RETRY_DELAY).await; + continue; + } + }, + _ = shutdown_signal.wait() => break, + }; + + let state = state.clone(); + let router = router.clone(); + let tls = tls.clone(); + let guard = crate::shutdown::task_guard(); + + tokio::spawn(async move { + let _guard = guard; + match tls { + Some(acceptor) => { + let handshake: Result<_, anyhow::Error> = async { + let tls_stream = + timeout(TLS_HANDSHAKE_TIMEOUT, acceptor.accept(stream)).await??; + Ok(tls_stream) + } + .await; + + let tls_stream = match handshake { + Ok(s) => s, + Err(e) => { + warn!(peer = %peer_addr, error = ?e, "mTLS handshake rejected"); + return; + } + }; + + // Read the peer's certificate chain before moving the + // stream into `TokioIo` — `peer_certificates()` is only + // reachable through the raw rustls connection. + let client_identity = tls_stream + .get_ref() + .1 + .peer_certificates() + .and_then(client_ca::identity_from_peer_certs) + .map(Arc::new); + + if let Err(e) = + handle_connection(tls_stream, peer_addr, state, router, client_identity) + .await + { + warn!(peer = %peer_addr, error = ?e, "connection error"); + } + } + None => { + if let Err(e) = handle_connection(stream, peer_addr, state, router, None).await + { + warn!(peer = %peer_addr, error = ?e, "connection error"); + } + } + } + }); + } + + // Closing the port is what stops new work: anything that connects from + // here on is refused rather than accepted into a process on its way out. + // Dropped explicitly rather than at the end of the scope so the port is + // provably shut before the line below claims it is. + drop(listener); + info!(listener = name, "listener closed — draining connections"); + Ok(()) +} + // ── Axum route handlers ───────────────────────────────────────────────── async fn healthz() -> axum::Json { @@ -715,12 +894,16 @@ fn is_http_proxy_request(req: &Request) -> bool { /// Uses a `service_fn` wrapper that intercepts CONNECT requests before they reach /// the Axum router (CONNECT URIs like `host:port` don't match Axum's path-based routing). /// All other HTTP routes (vault API, healthz, etc.) go through the Axum router. -async fn handle_connection( - stream: TcpStream, +async fn handle_connection( + stream: S, peer_addr: SocketAddr, state: GatewayState, router: Router, -) -> Result<()> { + client_identity: Option>, +) -> Result<()> +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ let io = TokioIo::new(stream); let conn = http1::Builder::new() @@ -731,11 +914,12 @@ async fn handle_connection( service_fn(move |req: Request| { let state = state.clone(); let router = router.clone(); + let client_identity = client_identity.clone(); async move { if req.method() == Method::CONNECT { - handle_connect(req, peer_addr, state).await + handle_connect(req, peer_addr, state, client_identity).await } else if is_http_proxy_request(&req) { - handle_http_proxy(req, peer_addr, state).await + handle_http_proxy(req, peer_addr, state, client_identity).await } else { // Axum handles all non-proxy routes (healthz, vault API, fallback) let resp: Response = router @@ -773,6 +957,7 @@ async fn handle_connect( req: Request, peer_addr: SocketAddr, state: GatewayState, + client_identity: Option>, ) -> Result, anyhow::Error> { let host = req .uri() @@ -848,6 +1033,7 @@ async fn handle_connect( org_id = organization_id.as_deref().unwrap_or("-"), agent = agent_name.as_deref().unwrap_or("-"), agent_id = agent_id.as_deref().unwrap_or("-"), + client_identity = client_identity.as_deref().and_then(ClientIdentity::primary).unwrap_or("-"), ); info!( @@ -880,6 +1066,7 @@ async fn handle_connect( agent_name, agent_identifier, agent_token: agent_token.clone(), + client_identity, }); // Taken here, before the spawn, so the session is tracked from the moment @@ -943,6 +1130,7 @@ async fn handle_http_proxy( req: Request, peer_addr: SocketAddr, state: GatewayState, + client_identity: Option>, ) -> Result, anyhow::Error> { let authority = req .uri() @@ -1074,6 +1262,7 @@ async fn handle_http_proxy( org_id = resolved.organization_id.as_deref().unwrap_or("-"), agent = resolved.agent_name.as_deref().unwrap_or("-"), agent_id = resolved.agent_id.as_deref().unwrap_or("-"), + client_identity = client_identity.as_deref().and_then(ClientIdentity::primary).unwrap_or("-"), ); info!( @@ -1090,6 +1279,7 @@ async fn handle_http_proxy( agent_name: resolved.agent_name, agent_identifier: resolved.agent_identifier, agent_token, + client_identity, }; let rules = mitm::ResolvedRules { @@ -1268,6 +1458,215 @@ mod tests { ); } + /// Build a `GatewayState` cheap enough for tests: a lazily-connected pool + /// (`connect_lazy` never actually dials — nothing exercised by these + /// tests touches the DB), an in-memory cache/approval store, and a local + /// `CryptoService` key. None of this is real credential material; it + /// exists only so the type checker is satisfied and `/healthz` (which + /// touches none of these fields) can be routed to through the real + /// `handle_connection` path. + async fn test_gateway_state() -> GatewayState { + use base64::Engine; + + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://test:test@127.0.0.1/test") + .expect("lazy pool"); + let crypto = Arc::new( + crate::crypto::CryptoService::from_base64_key( + &base64::engine::general_purpose::STANDARD.encode([0u8; 32]), + ) + .expect("crypto"), + ); + let onepassword = Arc::new(crate::vault::onepassword::OnePasswordVaultProvider::new( + pool.clone(), + Arc::clone(&crypto), + )); + let policy_engine = Arc::new(PolicyEngine { + pool: pool.clone(), + crypto: Arc::clone(&crypto), + onepassword: Arc::clone(&onepassword), + }); + let bitwarden = crate::vault::bitwarden::BitwardenVaultProvider::new( + crate::vault::bitwarden::BitwardenConfig { + proxy_url: "wss://example.invalid".to_string(), + }, + pool.clone(), + Arc::clone(&crypto), + ); + let providers: Vec> = vec![Arc::new(bitwarden), onepassword]; + let vault_service = Arc::new(vault::VaultService::new(providers, pool.clone())); + let cache = crate::cache::create_store().await.expect("cache store"); + let approval_store = crate::approval::create_store() + .await + .expect("approval store"); + + let tmp = tempfile::tempdir().expect("tempdir"); + let ca = crate::ca::CertificateAuthority::load_or_generate(tmp.path()) + .await + .expect("test ca"); + + GatewayState { + ca: Arc::new(ca), + http_client: build_http_client(false), + http_client_no_verify: build_http_client(true), + skip_verify_hosts: Arc::new(vec![]), + ws_connector: TlsConnector::from(build_ws_tls_config(false)), + ws_connector_no_verify: TlsConnector::from(build_ws_tls_config(true)), + policy_engine, + cache, + vault_service, + approval_store, + } + } + + /// End-to-end proof that Phase 1 mTLS *threads* identity but does not + /// *enforce* on it: a client cert that verifies successfully is extracted + /// into a `ClientIdentity` (asserted below), but a plain `GET /healthz` + /// over that same connection still gets a normal 200 through the real + /// `handle_connection` path — nothing gates on the identity yet. + #[tokio::test] + async fn mtls_valid_cert_reaches_healthz_with_no_enforcement() { + static INIT: std::sync::Once = std::sync::Once::new(); + INIT.call_once(|| { + let _ = rustls::crypto::ring::default_provider().install_default(); + }); + + // Client CA + a leaf it signs. + let ca_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).expect("ca key"); + let mut ca_params = rcgen::CertificateParams::default(); + ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + ca_params + .distinguished_name + .push(rcgen::DnType::CommonName, "Test Client CA"); + ca_params.not_before = time::OffsetDateTime::now_utc() - time::Duration::hours(1); + ca_params.not_after = time::OffsetDateTime::now_utc() + time::Duration::days(1); + let ca_cert = ca_params.self_signed(&ca_key).expect("self-sign ca"); + let ca_pem = ca_cert.pem(); + + let leaf_key = + rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).expect("leaf key"); + let mut leaf_params = rcgen::CertificateParams::default(); + leaf_params + .distinguished_name + .push(rcgen::DnType::CommonName, "agent-1"); + leaf_params.not_before = time::OffsetDateTime::now_utc() - time::Duration::hours(1); + leaf_params.not_after = time::OffsetDateTime::now_utc() + time::Duration::hours(24); + let leaf_cert = leaf_params + .signed_by(&leaf_key, &ca_cert, &ca_key) + .expect("sign leaf"); + let leaf_pem = leaf_cert.pem(); + let leaf_key_pem = leaf_key.serialize_pem(); + + // The gateway's own mTLS listener cert (self-signed, "localhost"). + let server_key = + rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).expect("server key"); + let mut server_params = + rcgen::CertificateParams::new(vec!["localhost".to_string()]).expect("params"); + server_params.not_before = time::OffsetDateTime::now_utc() - time::Duration::hours(1); + server_params.not_after = time::OffsetDateTime::now_utc() + time::Duration::days(1); + let server_cert = server_params + .self_signed(&server_key) + .expect("self-sign server"); + let server_pem = server_cert.pem(); + let server_key_pem = server_key.serialize_pem(); + let server_der = server_cert.der().clone(); + + // A distinct CA standing in for "the gateway's MITM CA" — only used + // to satisfy `from_parts`'s signature; unrelated to the client CA above. + let mitm_key = + rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).expect("mitm key"); + let mitm_cert = rcgen::CertificateParams::default() + .self_signed(&mitm_key) + .expect("self-sign mitm"); + let mitm_der = mitm_cert.der().clone(); + + // Build the real ServerConfig through the same path main.rs uses. + let mtls = client_ca::MtlsConfig::from_parts( + Some("10256"), + Some(&server_pem), + Some(&server_key_pem), + Some(&ca_pem), + &mitm_der, + 10255, + ) + .expect("from_parts") + .expect("mtls configured"); + + // Client trusts the server's self-signed cert directly and presents + // the CA-signed leaf. + let mut roots = rustls::RootCertStore::empty(); + roots.add(server_der).expect("trust server cert"); + let mut key_reader = leaf_key_pem.as_bytes(); + let client_key = rustls_pemfile::private_key(&mut key_reader) + .expect("parse client key") + .expect("client key present"); + let mut cert_reader = leaf_pem.as_bytes(); + let client_chain: Vec<_> = rustls_pemfile::certs(&mut cert_reader) + .collect::>() + .expect("client chain"); + let client_config = Arc::new( + rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_client_auth_cert(client_chain, client_key) + .expect("client auth cert"), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let addr = listener.local_addr().expect("addr"); + + let router = Router::new() + .route("/healthz", axum::routing::get(healthz)) + .fallback(fallback); + let state = test_gateway_state().await; + + let server_task = tokio::spawn(async move { + let (stream, peer_addr) = listener.accept().await.expect("accept"); + let acceptor = TlsAcceptor::from(Arc::clone(&mtls.server_config)); + let tls_stream = acceptor.accept(stream).await.expect("server handshake"); + + let client_identity = tls_stream + .get_ref() + .1 + .peer_certificates() + .and_then(client_ca::identity_from_peer_certs) + .map(Arc::new); + assert!(client_identity.is_some(), "identity must be extracted"); + + handle_connection(tls_stream, peer_addr, state, router, client_identity).await + }); + + let client_stream = tokio::net::TcpStream::connect(addr).await.expect("connect"); + let server_name = rustls::pki_types::ServerName::try_from("localhost").expect("name"); + let mut tls_client = tokio_rustls::TlsConnector::from(client_config) + .connect(server_name, client_stream) + .await + .expect("client handshake"); + + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + tls_client + .write_all(b"GET /healthz HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + .await + .expect("write request"); + + let mut response = Vec::new(); + tls_client + .read_to_end(&mut response) + .await + .expect("read response"); + let response_str = String::from_utf8_lossy(&response); + assert!( + response_str.starts_with("HTTP/1.1 200"), + "expected 200 OK (no enforcement in Phase 1), got: {response_str}" + ); + + server_task + .await + .expect("server task panicked") + .expect("connection handled"); + } + // ── strip_port ────────────────────────────────────────────────────── #[test] @@ -1352,6 +1751,54 @@ mod tests { assert!(parse_patterns("").is_empty()); } + // ── parse_plain_bind_value ─────────────────────────────────────────── + + #[test] + fn plain_bind_defaults_to_unspecified_when_unset() { + assert_eq!( + parse_plain_bind_value(None).unwrap(), + IpAddr::V4(Ipv4Addr::UNSPECIFIED) + ); + } + + #[test] + fn plain_bind_defaults_to_unspecified_when_empty() { + assert_eq!( + parse_plain_bind_value(Some("")).unwrap(), + IpAddr::V4(Ipv4Addr::UNSPECIFIED) + ); + assert_eq!( + parse_plain_bind_value(Some(" ")).unwrap(), + IpAddr::V4(Ipv4Addr::UNSPECIFIED) + ); + } + + #[test] + fn plain_bind_parses_valid_ip() { + assert_eq!( + parse_plain_bind_value(Some("127.0.0.1")).unwrap(), + IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)) + ); + } + + /// FIX 1: a set-but-unparseable value must fail closed (`Err`), not + /// silently fall back to the wide-open `0.0.0.0` default — that default + /// is exactly what this knob exists to let an operator narrow. + #[test] + fn plain_bind_unparseable_value_errs_naming_var_and_value() { + let err = parse_plain_bind_value(Some("127.0.0.q")).unwrap_err(); + let msg = format!("{err:#}"); + assert!(msg.contains("GATEWAY_PLAIN_BIND"), "message: {msg}"); + assert!(msg.contains("127.0.0.q"), "message: {msg}"); + } + + #[test] + fn plain_bind_hostname_is_not_an_ip_literal_errs() { + // "localhost" is a valid hostname but not an IP literal — parsing it + // as an IpAddr must fail rather than silently resolve or default. + assert!(parse_plain_bind_value(Some("localhost")).is_err()); + } + // ── is_http_proxy_request ────────────────────────────────────────── #[test] diff --git a/apps/gateway/src/gateway/mitm.rs b/apps/gateway/src/gateway/mitm.rs index 47507143..0e0dea6f 100644 --- a/apps/gateway/src/gateway/mitm.rs +++ b/apps/gateway/src/gateway/mitm.rs @@ -479,10 +479,8 @@ mod tests { ProxyContext { project_id: Some("p1".to_string()), organization_id: Some("o1".to_string()), - agent_id: None, - agent_name: None, - agent_identifier: None, agent_token: Some("tok".to_string()), + ..Default::default() } } diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs index d66b9f19..edca7c79 100644 --- a/apps/gateway/src/main.rs +++ b/apps/gateway/src/main.rs @@ -10,6 +10,7 @@ mod auth; mod auth; mod ca; +mod client_ca; #[cfg(not(edition_cloud))] mod cache; @@ -239,6 +240,16 @@ async fn main() -> Result<()> { let ca = CertificateAuthority::load_or_generate(&data_dir).await?; info!("CA certificate loaded"); + // mTLS is opt-in: unset GATEWAY_MTLS_PORT and this is a no-op (full + // backward compatibility). When it IS requested, any load failure here + // must abort startup — the gateway must never silently fall back to + // plaintext-only when mTLS was asked for. + let mtls = client_ca::MtlsConfig::from_env(ca.ca_cert_der(), cli.port)?; + match &mtls { + Some(m) => info!(port = m.port, "mTLS client-certificate listener configured"), + None => info!("mTLS disabled (GATEWAY_MTLS_PORT not set)"), + } + // Connect to PostgreSQL // Support both DATABASE_URL (OSS) and individual DB_* vars (cloud ECS from Secrets Manager) let database_url = match std::env::var("DATABASE_URL") { @@ -317,7 +328,8 @@ async fn main() -> Result<()> { vault_service, cache, approval_store, - ); + mtls, + )?; let result = server.run().await; // The drain, in the one order that does not lose data: connections first diff --git a/apps/gateway/src/policy_engine.rs b/apps/gateway/src/policy_engine.rs index c23d92da..54eb2e9c 100644 --- a/apps/gateway/src/policy_engine.rs +++ b/apps/gateway/src/policy_engine.rs @@ -4,19 +4,23 @@ //! 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 types; // The corpus parity test lives in the PRIVATE tree (`src/ee/policy_engine/`) 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/enforce.rs b/apps/gateway/src/policy_engine/enforce.rs index 1e8d0f13..8ff8cf8d 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,14 +19,15 @@ 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 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. @@ -29,37 +35,54 @@ pub(crate) fn needs_body_buffer(_v2: &PolicyV2Rules) -> bool { 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 +90,11 @@ pub(crate) async fn load_connect_v2( ConnectionProviders::default() }; Ok(PolicyV2Rules { + org, project, + principals, secret_hosts, connection_providers, - ..PolicyV2Rules::default() }) } @@ -122,10 +146,9 @@ 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, @@ -148,7 +171,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 +196,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, body) { Outcome::Rule(rule) => ( decision_for_rule(rule, org_id, project_id, agent_token, cache).await, Some(matched_of(rule)), @@ -176,3 +210,130 @@ 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_token: Some("t".to_string()), + ..Default::default() + } + } + + 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", + "/", + None, + 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")); + } +} diff --git a/apps/gateway/src/policy_engine/evaluate.rs b/apps/gateway/src/policy_engine/evaluate.rs index 46511ebd..a2b897ff 100644 --- a/apps/gateway/src/policy_engine/evaluate.rs +++ b/apps/gateway/src/policy_engine/evaluate.rs @@ -1,23 +1,35 @@ -//! 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. +use crate::db::PrincipalSet; use crate::policy::{matches_request, 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, }) } @@ -94,8 +106,13 @@ 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, + body: Option<&[u8]>, +) -> bool { + identity_matches(rule, request, principals) && !rule.targets.is_empty() && rule .targets @@ -103,35 +120,128 @@ fn rule_matches(rule: &Rule, request: &Request, body: Option<&[u8]>) -> bool { .any(|t| target_matches(t, rule, request, body)) } -/// 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, + body: Option<&[u8]>, +) -> 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, body)) + .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, + principals: &PrincipalSet, body: Option<&[u8]>, ) -> 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, body); + let project_match = first_match(project_rules, request, principals, body); + + // 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 +250,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 +275,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 +302,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 +339,12 @@ 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(), None) { Outcome::Rule(r) => assert_eq!(r.id, "a"), _ => panic!("expected a rule match"), } @@ -257,13 +356,14 @@ 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(), None) { 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 +373,43 @@ 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(), None) { 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(), None) { // 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(), None) { 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(), None), 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(), None) { Outcome::DenyDefault(d) => assert!(d.is_default), _ => panic!("expected the deny-default"), } @@ -314,17 +417,17 @@ 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(), None), 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(), None) { 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,28 +435,350 @@ 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(), None), Outcome::Allow )); } + /// Test #11 (part): the OSS condition arm is the no-op — a conditioned block + /// matches vacuously. This pins the Stage-G seam; if OSS ships real + /// condition matching this test must flip with it. #[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) { + match evaluate_outcome(&[], &[conditioned], &request(), &no_principals(), None) { Outcome::Rule(r) => assert_eq!(r.id, "cond"), _ => panic!("expected the conditioned rule to match vacuously"), } } + // ── 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(), None), + Outcome::Rule(r) if r.action == Action::Block + )); + assert!(matches!( + evaluate_outcome(&[], &blk, &req_via(None), &no_principals(), None), + Outcome::Allow + )); + // A same-provider sibling account → no match. + assert!(matches!( + evaluate_outcome(&[], &blk, &req_via(Some("c2")), &no_principals(), None), + 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(), + None, + ) { + 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(), + None + ), + 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(), None) { + 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(), None) { + 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(), None), + 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(), None), + 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(), None), + 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(), None) { + 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(), None) { + 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(), None) { + 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(), None) { + 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(), None) { + 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(), None) { + 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(), None) { + 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(), None) { + 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(), None) { + 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(), None) { + 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(), None) { + 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(), None) { + 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(), None) { + 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(), None) { + 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(), None), + Outcome::Allow + )); + match evaluate_outcome(&org, &[], &injected_request(), &no_principals(), None) { + 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(), None), + Outcome::Allow + )); + match evaluate_outcome(&[], &project, &injected_request(), &no_principals(), None) { + 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(), None), + Outcome::Allow + )); + } + #[test] fn rate_window_secs_mapping() { assert_eq!(RateWindow::Minute.secs(), 60); 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/types.rs b/apps/gateway/src/policy_engine/types.rs index 56f32ba5..85470964 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, @@ -115,14 +138,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/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/_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)/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..5479fe50 --- /dev/null +++ b/apps/web/src/app/(dashboard)/groups/_components/groups-content.tsx @@ -0,0 +1,47 @@ +"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"; + +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 ; +}; 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/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)/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/hooks/use-projects.ts b/apps/web/src/hooks/use-projects.ts index fa84f85d..1ca3f427 100644 --- a/apps/web/src/hooks/use-projects.ts +++ b/apps/web/src/hooks/use-projects.ts @@ -1,8 +1,9 @@ "use client"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { toast } from "sonner"; import { projects } from "@/lib/api"; +import { queryKeys } from "@/lib/api/keys"; // Project rename/delete go through the audited `/v1/projects/:id` routes. Delete // flushes the gateway cache for the removed keys server-side, so there is @@ -10,6 +11,14 @@ import { projects } from "@/lib/api"; // callers handle the on-success refresh/redirect themselves (as the old actions // did) rather than invalidating a query cache. +/** The current project's row (name/slug/createdAt) for the settings page. */ +export const useProject = (projectId: string | undefined) => + useQuery({ + queryKey: queryKeys.projects.detail(projectId ?? ""), + queryFn: () => projects.get(projectId ?? ""), + enabled: Boolean(projectId), + }); + export const useRenameProject = () => useMutation({ mutationFn: ({ id, name }: { id: string; name: string }) => diff --git a/apps/web/src/lib/api/client.ts b/apps/web/src/lib/api/client.ts index 405231ad..89ca8b6c 100644 --- a/apps/web/src/lib/api/client.ts +++ b/apps/web/src/lib/api/client.ts @@ -8,11 +8,30 @@ const extractErrorMessage = (body: Record, status: number) => { return `Request failed: ${status}`; }; +/** + * A failed API response. Still a plain `Error` (every `err instanceof Error` + * toast keeps working), plus the HTTP status — a 403 from an admin-only route + * is an EXPECTED outcome some surfaces render differently from a transport + * failure, and the message alone cannot tell them apart. + */ +export class ApiError extends Error { + readonly status: number; + + constructor(message: string, status: number) { + super(message); + this.name = "ApiError"; + this.status = status; + } +} + +const toApiError = (body: Record, status: number) => + new ApiError(extractErrorMessage(body, status), status); + export const apiGet = async (path: string): Promise => { const res = await apiFetch(path); if (!res.ok) { const body = await res.json().catch(() => ({})); - throw new Error(extractErrorMessage(body, res.status)); + throw toApiError(body, res.status); } return res.json(); }; @@ -24,7 +43,7 @@ export const apiPost = async (path: string, body: unknown): Promise => { }); if (!res.ok) { const data = await res.json().catch(() => ({})); - throw new Error(extractErrorMessage(data, res.status)); + throw toApiError(data, res.status); } return res.json(); }; @@ -36,7 +55,7 @@ export const apiPatch = async (path: string, body: unknown): Promise => { }); if (!res.ok) { const data = await res.json().catch(() => ({})); - throw new Error(extractErrorMessage(data, res.status)); + throw toApiError(data, res.status); } return res.json(); }; @@ -48,7 +67,7 @@ export const apiPut = async (path: string, body: unknown): Promise => { }); if (!res.ok) { const data = await res.json().catch(() => ({})); - throw new Error(extractErrorMessage(data, res.status)); + throw toApiError(data, res.status); } return res.json(); }; @@ -57,6 +76,6 @@ export const apiDelete = async (path: string): Promise => { const res = await apiFetch(path, { method: "DELETE" }); if (!res.ok) { const body = await res.json().catch(() => ({})); - throw new Error(extractErrorMessage(body, res.status)); + throw toApiError(body, res.status); } }; diff --git a/apps/web/src/lib/api/index.ts b/apps/web/src/lib/api/index.ts index 9b662576..08c53b62 100644 --- a/apps/web/src/lib/api/index.ts +++ b/apps/web/src/lib/api/index.ts @@ -108,4 +108,5 @@ export type { AppPermissionDefinitionSummary, } from "@onecli/api/apps/app-permissions/types"; export { apiGet, apiPost, apiPatch, apiPut, apiDelete } from "./client"; +export { ApiError } from "./client"; export { queryKeys } from "./keys"; diff --git a/apps/web/src/lib/api/keys.ts b/apps/web/src/lib/api/keys.ts index 4138a1c3..cecc88c7 100644 --- a/apps/web/src/lib/api/keys.ts +++ b/apps/web/src/lib/api/keys.ts @@ -70,6 +70,11 @@ export const queryKeys = { byProvider: (provider: string) => [...queryKeys.connections.all(), "provider", provider] as const, }, + projects: { + all: () => ["projects", ...scope()] as const, + detail: (projectId: string) => + [...queryKeys.projects.all(), projectId] as const, + }, projectAccess: { all: () => ["project-access", ...scope()] as const, list: (projectId: string) => diff --git a/apps/web/src/lib/api/projects.ts b/apps/web/src/lib/api/projects.ts index 5f946cb8..311e419e 100644 --- a/apps/web/src/lib/api/projects.ts +++ b/apps/web/src/lib/api/projects.ts @@ -1,6 +1,11 @@ -import { apiPatch, apiDelete } from "./client"; +import { apiGet, apiPatch, apiDelete } from "./client"; import type { Project } from "./types"; +// The project's own row. Nothing else in the API exposes a project's name +// (`GET /v1/auth/session` returns only `projectId`), so the settings page reads +// it here. +export const get = (id: string) => apiGet(`/v1/projects/${id}`); + export const rename = (id: string, name: string) => apiPatch(`/v1/projects/${id}`, { name }); diff --git a/apps/web/src/lib/components/condition-builder.tsx b/apps/web/src/lib/components/condition-builder.tsx index 97064d12..1bb1d88f 100644 --- a/apps/web/src/lib/components/condition-builder.tsx +++ b/apps/web/src/lib/components/condition-builder.tsx @@ -10,16 +10,8 @@ export interface ConditionBuilderProps { export const ConditionBuilder = ({}: ConditionBuilderProps) => (

- Match conditions (body content, headers) are available on{" "} - - OneCLI Cloud - - . + Match conditions (body content, headers) are not yet available in this + build.

); diff --git a/apps/web/src/lib/components/pro-app-dialog.tsx b/apps/web/src/lib/components/pro-app-dialog.tsx index cf6aa7a4..07030b94 100644 --- a/apps/web/src/lib/components/pro-app-dialog.tsx +++ b/apps/web/src/lib/components/pro-app-dialog.tsx @@ -1,7 +1,5 @@ "use client"; -import { ExternalLink } from "lucide-react"; -import { Button } from "@onecli/ui/components/button"; import { Dialog, DialogContent, @@ -9,6 +7,7 @@ import { DialogTitle, } from "@onecli/ui/components/dialog"; import { AppIcon } from "@/app/(dashboard)/connections/_components/app-icon"; +import { UnavailableBadge } from "@/lib/components/unavailable-badge"; interface ProAppDialogProps { appName: string; @@ -19,6 +18,13 @@ interface ProAppDialogProps { onOpenChange: (open: boolean) => void; } +/** + * Shown when the user opens something this build does not implement: an + * `available: false` registry app (Connections list) or a capability without an + * OSS implementation (granular access). Informational only — the dialog's close + * button is the only action. Every EE edition aliases this module away + * (`next.config.js` → `@/ee/apps/pro-app-dialog`). + */ export const ProAppDialog = ({ appName, appIcon, @@ -44,72 +50,16 @@ export const ProAppDialog = ({ {appName} -
- - - - - - Team - +
+

{description}

- Available on OneCLI Cloud and on-prem enterprise plans. + Not yet available in this build.

- -
- - -
diff --git a/apps/web/src/lib/components/team-badge.tsx b/apps/web/src/lib/components/team-badge.tsx deleted file mode 100644 index f9d9e4df..00000000 --- a/apps/web/src/lib/components/team-badge.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/** - * The house "Team" pill marking an app that needs a paid OneCLI plan — the - * same badge the Connections list (`apps-tab.tsx` AppRow) and `ProAppDialog` - * render inline. Extracted for the policy editor's cloud-only-app surfaces; - * the two existing inline copies are untouched (future cleanup). - */ -export const TeamBadge = () => ( - - - - - - - Team - - -); diff --git a/apps/web/src/lib/components/unavailable-badge.tsx b/apps/web/src/lib/components/unavailable-badge.tsx new file mode 100644 index 00000000..ca229ff7 --- /dev/null +++ b/apps/web/src/lib/components/unavailable-badge.tsx @@ -0,0 +1,11 @@ +/** + * The house pill marking an integration or capability this build does not + * implement (`available: false` registry entries, and the locked policy-editor + * surfaces). Rendered by the Connections list, the policy editor's app + * pickers, and `ProAppDialog`. + */ +export const UnavailableBadge = () => ( + + Unavailable + +); diff --git a/apps/web/src/lib/init/api.ts b/apps/web/src/lib/init/api.ts index 1207663a..a4a3faa9 100644 --- a/apps/web/src/lib/init/api.ts +++ b/apps/web/src/lib/init/api.ts @@ -1,6 +1,5 @@ import type { CreateApiAppOptions } from "@onecli/api"; import { ossNewProjectPolicySeeder } from "@onecli/api/services/policy-oss-cutover"; -import { ossPolicyValidator } from "@onecli/api/services/policy-oss-locks"; import { ossRoleResolver } from "@onecli/api/services/org-role-resolver"; import { registerOssOrgRoutes } from "@onecli/api/routes/org"; @@ -11,13 +10,15 @@ import { registerOssOrgRoutes } from "@onecli/api/routes/org"; * * - the new-project seeder gives fresh projects their published Default Rule — * the per-project enforce signal — pinned to ALLOW since step 6; - * - the policy validator LOCKS granular resource scoping (a OneCLI Cloud - * capability the OSS gateway does not enforce) with a loud 422; * - the role resolver backs `CAPS.rbac` (now true for OSS): it reads the * org-membership row and is a hard prerequisite for the flag — with rbac on * and no resolver, every access check reads "no role" and denies; * - the org routes register the OSS `/v1/org/*` surface. * + * No `policyValidator` is wired: the provider-hook default is permissive, so + * granular resource scoping and cloud-only app targets are accepted at the API + * layer (the gateway enforces resource scoping — see the Tier 3 work). + * * `eeRoutes` reads oddly for an OSS registration, but it IS the intended seam: * it is the one hook `createApiApp` exposes for edition-owned routes, and every * EE edition aliases this whole file away, so nothing here can collide with @@ -27,7 +28,6 @@ import { registerOssOrgRoutes } from "@onecli/api/routes/org"; */ export const eeOverrides: CreateApiAppOptions | undefined = { newOrgPolicySeeder: ossNewProjectPolicySeeder, - policyValidator: ossPolicyValidator, roleResolver: ossRoleResolver, eeRoutes: registerOssOrgRoutes, }; diff --git a/apps/web/src/lib/nav-config.ts b/apps/web/src/lib/nav-config.ts index a34a1a38..0cb6bb93 100644 --- a/apps/web/src/lib/nav-config.ts +++ b/apps/web/src/lib/nav-config.ts @@ -6,6 +6,7 @@ import { Activity, User, Users, + UsersRound, KeyRound, ShieldCheck, Globe, @@ -31,6 +32,10 @@ export const navItems: NavItem[] = [ // Always visible (D-J): the page itself degrades for non-admins and in // local auth mode — hiding the item would require a session role field. { title: "Team", url: "/team", icon: Users }, + // Always visible (D-J): the page itself degrades for non-admins and gates + // groups in local auth mode — hiding the item would require a session role + // field. + { title: "Groups", url: "/groups", icon: UsersRound }, { title: "Settings", url: "/settings", icon: Settings }, ]; diff --git a/apps/web/src/lib/policy-editor/_components/app-select.tsx b/apps/web/src/lib/policy-editor/_components/app-select.tsx index 1dd255b2..a3e831b1 100644 --- a/apps/web/src/lib/policy-editor/_components/app-select.tsx +++ b/apps/web/src/lib/policy-editor/_components/app-select.tsx @@ -11,7 +11,7 @@ import { } from "@onecli/ui/components/popover"; import { getApp, getApps } from "@onecli/api/apps/registry"; import { AppIcon } from "@/app/(dashboard)/connections/_components/app-icon"; -import { TeamBadge } from "@/lib/components/team-badge"; +import { UnavailableBadge } from "@/lib/components/unavailable-badge"; /** * True when the registry knows the app but this edition can't connect it — @@ -96,7 +96,7 @@ export const AppSelect = ({ value, onChange, id, invalid }: AppSelectProps) => { size={18} /> {selectedApp.name} - {!selectedApp.available && } + {!selectedApp.available && } ) : ( Select an app… @@ -141,7 +141,7 @@ export const AppSelect = ({ value, onChange, id, invalid }: AppSelectProps) => { {a.name} - {!a.available && } + {!a.available && } {a.id === value && ( )} diff --git a/apps/web/src/lib/policy-editor/_components/app-target-fields.tsx b/apps/web/src/lib/policy-editor/_components/app-target-fields.tsx index 813b50de..94c1ffba 100644 --- a/apps/web/src/lib/policy-editor/_components/app-target-fields.tsx +++ b/apps/web/src/lib/policy-editor/_components/app-target-fields.tsx @@ -13,10 +13,11 @@ import { cn } from "@onecli/ui/lib/utils"; import { getApp } from "@onecli/api/apps/registry"; import { AppSelect } from "./app-select"; import { AppToolsPicker } from "./app-tools-picker"; -import { TeamBadge } from "@/lib/components/team-badge"; +import { UnavailableBadge } from "@/lib/components/unavailable-badge"; // Edition seam: EE aliases to the real granular resource editor; the OSS -// module is a locked "available on OneCLI Cloud" hint. Alias key on purpose — -// a relative import would bypass turbopack resolveAlias in EE builds. +// module is a locked "not available in this build" hint. Alias key on +// purpose — a relative import would bypass turbopack resolveAlias in EE +// builds. import { ResourceScopeFields } from "@/lib/policy-editor/resource-scope"; import type { Connection } from "@/lib/api"; @@ -144,18 +145,10 @@ export const AppTargetFields = ({ role="status" className="flex items-center gap-2.5 rounded-md border border-dashed px-3 py-2.5" > - +

- {providerName(value.provider)} connections are available on{" "} - - OneCLI Cloud - - . + {providerName(value.provider)} connections are not yet available in + this build.

) : ( diff --git a/apps/web/src/lib/policy-editor/identity-picker.tsx b/apps/web/src/lib/policy-editor/identity-picker.tsx index 85f7b932..2979506b 100644 --- a/apps/web/src/lib/policy-editor/identity-picker.tsx +++ b/apps/web/src/lib/policy-editor/identity-picker.tsx @@ -4,7 +4,7 @@ import type { ProjectionIdentity } from "@/lib/api"; /** * The OSS identity-picker seam (step 9.5). Directory identities (users, - * user-groups) are a OneCLI Cloud capability, and since attach-model step 6 + * user-groups) are not implemented in this build, and since attach-model step 6 * the only policy console left is the ORG one — which OSS does not mount at * all. So this stub can never render; it exists to keep the shared rule form * compiling in an OSS build. The EE editions alias this file to diff --git a/apps/web/src/lib/policy-editor/resource-scope.tsx b/apps/web/src/lib/policy-editor/resource-scope.tsx index e01916ca..d905e195 100644 --- a/apps/web/src/lib/policy-editor/resource-scope.tsx +++ b/apps/web/src/lib/policy-editor/resource-scope.tsx @@ -5,11 +5,11 @@ import type { Connection } from "@/lib/api"; /** * The OSS resource-scope seam (step 9.5): granular per-resource scoping * (GitHub repositories / Dropbox folders on a connection's injected - * credential) is a OneCLI Cloud capability — the OSS gateway has no guard to - * enforce it and the API locks it with a 422. Rendered only where the real - * editor would appear (a single specific connection on an Allow), as a locked - * capability hint. The EE editions alias this file to - * `@/ee/policy-editor/resource-scope` (the real fields). + * credential) is not implemented in this build — the gateway has no guard to + * enforce it (Tier 3). Rendered only where the real editor would appear (a + * single specific connection on an Allow), as a locked capability hint. The + * EE editions alias this file to `@/ee/policy-editor/resource-scope` (the + * real fields). */ export interface ResourceScopeFieldsProps { @@ -23,6 +23,6 @@ export const ResourceScopeFields: ( ) => React.JSX.Element = () => (

Resource scoping (limit this connection to specific repositories or folders) - is available on OneCLI Cloud. + is not yet available in this build.

); diff --git a/apps/web/src/lib/user-plan.tsx b/apps/web/src/lib/user-plan.tsx index 172ea050..c6997ec0 100644 --- a/apps/web/src/lib/user-plan.tsx +++ b/apps/web/src/lib/user-plan.tsx @@ -3,5 +3,10 @@ /** OSS default: no redirect needed. The EE editions override this via turbopack alias. */ export const checkDashboardRedirect = async (): Promise => null; -/** OSS default: no plan. The EE editions override this via turbopack alias. */ -export const getCurrentPlan = async (): Promise => null; +/** + * This build is fully entitled — mirrors what on-prem reports via + * ONPREM_ENTITLEMENT_ALIASES (`next.config.js`), so plan-gated apps and + * features are never shown as locked. The EE editions override this via + * turbopack alias. + */ +export const getCurrentPlan = async (): Promise => "enterprise"; diff --git a/docker/Dockerfile b/docker/Dockerfile index f86fed37..6c706fd9 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -117,7 +117,7 @@ VOLUME ["/app/data"] USER node -EXPOSE 10254 10255 +EXPOSE 10254 10255 10256 HEALTHCHECK --interval=10s --timeout=5s --start-period=60s --retries=3 \ CMD wget -qO- http://127.0.0.1:10254/v1/health && wget -qO- http://127.0.0.1:10255/healthz || exit 1 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index f7365003..220e6c39 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -31,6 +31,9 @@ services: ports: - "${ONECLI_BIND_HOST:-127.0.0.1}:${ONECLI_APP_PORT:-10254}:10254" - "${ONECLI_BIND_HOST:-127.0.0.1}:${ONECLI_GATEWAY_PORT:-10255}:10255" + # mTLS client-certificate listener — off by default (GATEWAY_MTLS_PORT + # unset). Uncomment to publish it, and set the env block below. + # - "${ONECLI_BIND_HOST:-127.0.0.1}:${ONECLI_GATEWAY_MTLS_PORT:-10256}:10256" environment: DATABASE_URL: postgresql://${POSTGRES_USER:-onecli}:${POSTGRES_PASSWORD:-onecli}@postgres:5432/${POSTGRES_DB:-onecli} NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-} @@ -53,6 +56,14 @@ services: # sending internal vault calls out over the internet, whatever version the # `image:` above pins. INTERNAL_API_URL: http://localhost:10254 + # mTLS is OFF unless GATEWAY_MTLS_PORT is set — uncomment all four to + # enable it. GATEWAY_TLS_CERT/KEY/CLIENT_CA accept either an inline PEM + # value (starts with "-----BEGIN") or a filesystem path mounted into the + # container. GATEWAY_CLIENT_CA must NOT be the gateway's own MITM CA. + # GATEWAY_MTLS_PORT: "10256" + # GATEWAY_TLS_CERT: /run/secrets/gateway-tls-cert.pem + # GATEWAY_TLS_KEY: /run/secrets/gateway-tls-key.pem + # GATEWAY_CLIENT_CA: /run/secrets/gateway-client-ca.pem volumes: - app-data:/app/data env_file: diff --git a/packages/api/src/apps/connect-credentials.test.ts b/packages/api/src/apps/connect-credentials.test.ts index 3882bad4..830c7ffa 100644 --- a/packages/api/src/apps/connect-credentials.test.ts +++ b/packages/api/src/apps/connect-credentials.test.ts @@ -112,7 +112,7 @@ describe("resolveConnectCredentials", () => { }); expect(result).toEqual({ ok: false, - error: 'Provider "cloudy" is only available in OneCLI Cloud', + error: 'Provider "cloudy" is not yet available in this build', }); }); diff --git a/packages/api/src/apps/connect-credentials.ts b/packages/api/src/apps/connect-credentials.ts index 754698a2..a8b72926 100644 --- a/packages/api/src/apps/connect-credentials.ts +++ b/packages/api/src/apps/connect-credentials.ts @@ -70,7 +70,7 @@ export const resolveConnectCredentials = async ( if (activeMethod.type === "cloud_only") { return { ok: false, - error: `Provider "${provider}" is only available in OneCLI Cloud`, + error: `Provider "${provider}" is not yet available in this build`, }; } diff --git a/packages/api/src/lib/gateway-invalidate.ts b/packages/api/src/lib/gateway-invalidate.ts index 334e8e13..6256031f 100644 --- a/packages/api/src/lib/gateway-invalidate.ts +++ b/packages/api/src/lib/gateway-invalidate.ts @@ -24,8 +24,13 @@ export const invalidateGatewayCache = (request: Request) => { /** * Flush the gateway's cached config for specific API keys directly. Use this - * when the keys are about to be — or have just been — deleted, so they can no - * longer be looked up from the database: capture them first, then flush. + * when the keys are about to be deleted, so they can no longer be looked up + * from the database: capture them, flush, THEN delete. + * + * The order is load-bearing. The gateway authenticates `/v1/cache/invalidate` + * by resolving the bearer through an uncached `find_api_key` query, so a key + * that has already been deleted cannot flush its own entry — the request just + * 401s and the rejection is swallowed. */ export const invalidateGatewayCacheForKeys = (keys: string[]) => { for (const key of keys) { diff --git a/packages/api/src/lib/policy-flags.test.ts b/packages/api/src/lib/policy-flags.test.ts deleted file mode 100644 index 6ad9cb89..00000000 --- a/packages/api/src/lib/policy-flags.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { isOssEdition } from "./policy-flags"; - -// The OSS edition drives how the shared policy service phrases capability -// rejections (a OneCLI Cloud pointer there, byte-identical everywhere else), so -// the edition resolution itself is pinned: EDITION first, NEXT_PUBLIC_EDITION as -// the fallback, and an unset/unknown value parsing as OSS. -describe("isOssEdition", () => { - const originalEdition = process.env.EDITION; - const originalPublicEdition = process.env.NEXT_PUBLIC_EDITION; - - afterEach(() => { - if (originalEdition === undefined) delete process.env.EDITION; - else process.env.EDITION = originalEdition; - if (originalPublicEdition === undefined) - delete process.env.NEXT_PUBLIC_EDITION; - else process.env.NEXT_PUBLIC_EDITION = originalPublicEdition; - }); - - it.each([ - ["oss", true], - ["onprem-slim", false], - ["onprem-full", false], - ["cloud", false], - ["", true], // unset edition parses as oss - ])("edition %s → %s", (edition, expected) => { - delete process.env.NEXT_PUBLIC_EDITION; - process.env.EDITION = edition; - expect(isOssEdition()).toBe(expected); - }); - - it("falls back to NEXT_PUBLIC_EDITION when EDITION is unset", () => { - delete process.env.EDITION; - process.env.NEXT_PUBLIC_EDITION = "cloud"; - expect(isOssEdition()).toBe(false); - }); -}); diff --git a/packages/api/src/lib/policy-flags.ts b/packages/api/src/lib/policy-flags.ts deleted file mode 100644 index 138be7c0..00000000 --- a/packages/api/src/lib/policy-flags.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Policy runtime edition helpers. Pure and dependency-free (reads only - * `process.env` plus the pure edition parser), so it is safe to import from - * routes, middleware, or a standalone startup entry. - */ -import { parseEdition } from "./edition"; - -const runtimeEdition = () => - parseEdition(process.env.EDITION ?? process.env.NEXT_PUBLIC_EDITION).edition; - -/** Whether this runtime is the OSS edition — used by the shared policy - * service to phrase capability rejections as OneCLI Cloud pointers there - * (byte-identical messages everywhere else). */ -export const isOssEdition = (): boolean => runtimeEdition() === "oss"; diff --git a/packages/api/src/providers/hooks/policy-validator.ts b/packages/api/src/providers/hooks/policy-validator.ts index 7f3eb03b..b1e9731b 100644 --- a/packages/api/src/providers/hooks/policy-validator.ts +++ b/packages/api/src/providers/hooks/policy-validator.ts @@ -10,8 +10,7 @@ export interface PolicyValidator { /** * Edition gate over a rule's targets, run on create/update (never publish — * a pre-existing row must not brick a whole-scope publish). Absent = - * permissive (the default); the OSS edition wires an implementation that - * rejects app targets for cloud-only providers its gateway can't enforce. + * permissive (the default); no edition in this repo wires one. */ validateTargets?(targets: PolicyTargetInput[]): Promise; } diff --git a/packages/api/src/routes/org/groups.test.ts b/packages/api/src/routes/org/groups.test.ts new file mode 100644 index 00000000..a2b64d21 --- /dev/null +++ b/packages/api/src/routes/org/groups.test.ts @@ -0,0 +1,1671 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Hono } from "hono"; +import type { ApiEnv } from "../../types"; + +// `/v1/org/groups` end-to-end through the real app: the OSS org routes +// mounted on the `eeRoutes` seam, the OSS role resolver wired as the +// RoleResolver, and `CAPS.rbac` on. Admin callers arrive with an org API key +// (whose key path re-checks admin through the resolver); the non-admin cases +// use a session, since a non-admin's org key fails key authentication +// outright. (Same harness as invitations.test.ts — cloned, not shared.) + +const ORG = "org-1"; +const OTHER_ORG = "org-2"; +const OWNER = "user-owner"; +const ADMIN = "user-admin"; +const MEMBER = "user-member"; +const OUTSIDER = "user-outsider"; +const ADMIN_KEY = "oc_org_admin-key"; +const PROJECT_KEY = "oc_project-key-of-owner"; + +vi.hoisted(() => { + process.env.NEXT_PUBLIC_EDITION = "oss"; + process.env.SECRET_ENCRYPTION_KEY = "test-secret"; + process.env.OAUTH_STATE_SECRET = "test-secret"; +}); + +interface MemberRow { + organizationId: string; + userId: string; + userEmail: string; + role: string; + status: string; + ssoExempt: boolean; + suspendedAt: Date | null; + createdAt: Date; +} + +interface UserRow { + id: string; + externalAuthId: string; + email: string; + name: string | null; +} + +interface GroupRow { + id: string; + organizationId: string; + name: string; + source: string; + externalId: string | null; + createdAt: Date; + updatedAt: Date; +} + +interface GroupMemberRow { + groupId: string; + userId: string; + createdByUserId: string | null; + createdAt: Date; +} + +interface ProjectAccessRow { + id: string; + projectId: string; + groupId: string; +} + +/** Full shape since Slice 5: the membership writers re-resolve these. */ +interface RoleMappingRow { + id: string; + organizationId: string; + groupId: string; + role: string; + priority: number; + createdAt: Date; + updatedAt: Date; +} + +interface AuditRow { + organizationId?: string; + userId: string; + action: string; + service: string; + source: string; + metadata: Record; +} + +const store = vi.hoisted(() => ({ + members: [] as MemberRow[], + users: [] as UserRow[], + groups: [] as GroupRow[], + groupMembers: [] as GroupMemberRow[], + projectAccess: [] as ProjectAccessRow[], + roleMappings: [] as RoleMappingRow[], + audits: [] as AuditRow[], + seq: 0, + txCount: 0, + /** Simulate a create-create race: the name pre-check misses, create P2002s. */ + race: false, + /** Which user the session provider resolves to (null = no session). */ + sessionUserId: null as string | null, +})); + +vi.mock("@onecli/db", () => { + class PrismaClientKnownRequestError extends Error { + code: string; + constructor(message: string, code: string) { + super(message); + this.code = code; + } + } + + // The subset of the Prisma `where` shapes these routes actually build. + interface KeysetClause { + createdAt?: Date | { gt?: Date }; + id?: { gt: string }; + userId?: { gt: string }; + } + interface GroupWhere { + id?: string | { not: string }; + organizationId?: string; + source?: string; + name?: string | { contains: string }; + /** The keyset predicate — the service nests it under AND, never top-level. */ + AND?: { OR: KeysetClause[] }[]; + } + interface GroupSelect { + id?: boolean; + name?: boolean; + source?: boolean; + externalId?: boolean; + createdAt?: boolean; + updatedAt?: boolean; + _count?: { select: { members?: boolean; projectAccess?: boolean } }; + roleMapping?: { select: { id: boolean } }; + } + interface GroupMemberWhere { + groupId?: string | { in: string[] }; + userId?: string | { in: string[] }; + user?: { + OR: { email?: { contains: string }; name?: { contains: string } }[]; + }; + AND?: { OR: KeysetClause[] }[]; + } + /** The role-mapping seam's reads (Slice 5). */ + interface MappingWhere { + id?: string; + organizationId?: string; + groupId?: string; + } + interface OrgMemberWhere { + organizationId?: string; + userId?: string | { in: string[] }; + role?: string | { not?: string }; + status?: string | { not?: string }; + } + + const matchesKeyset = ( + row: { createdAt: Date; id?: string; userId?: string }, + filter: { OR: KeysetClause[] }[] | undefined, + ) => { + if (!filter) return true; + return filter.every((conjunct) => + conjunct.OR.some((clause) => { + if (clause.createdAt instanceof Date) { + if (row.createdAt.getTime() !== clause.createdAt.getTime()) + return false; + if (clause.id !== undefined && row.id !== undefined) + return row.id > clause.id.gt; + if (clause.userId !== undefined && row.userId !== undefined) + return row.userId > clause.userId.gt; + return false; + } + const gt = clause.createdAt?.gt; + return gt !== undefined && row.createdAt.getTime() > gt.getTime(); + }), + ); + }; + + const filterGroups = (where: GroupWhere) => + store.groups.filter((row) => { + if (typeof where.id === "string" && row.id !== where.id) return false; + if ( + typeof where.id === "object" && + where.id !== null && + row.id === where.id.not + ) + return false; + if ( + where.organizationId !== undefined && + row.organizationId !== where.organizationId + ) + return false; + if (where.source !== undefined && row.source !== where.source) + return false; + if (typeof where.name === "string" && row.name !== where.name) + return false; + if ( + typeof where.name === "object" && + where.name !== null && + !row.name.toLowerCase().includes(where.name.contains.toLowerCase()) + ) + return false; + return matchesKeyset(row, where.AND); + }); + + // Mirror Prisma's `select` (incl. `_count` and the roleMapping relation) so + // a route can't accidentally leak a column the service didn't ask for. + const pickGroup = (row: GroupRow, select?: GroupSelect) => { + if (!select) return { ...row }; + const picked: Record = {}; + for (const key of [ + "id", + "name", + "source", + "externalId", + "createdAt", + "updatedAt", + ] as const) { + if (select[key]) picked[key] = row[key]; + } + if (select._count) { + const count: Record = {}; + if (select._count.select.members) { + count.members = store.groupMembers.filter( + (m) => m.groupId === row.id, + ).length; + } + if (select._count.select.projectAccess) { + count.projectAccess = store.projectAccess.filter( + (pa) => pa.groupId === row.id, + ).length; + } + picked._count = count; + } + if (select.roleMapping) { + const mapping = store.roleMappings.find((rm) => rm.groupId === row.id); + picked.roleMapping = mapping ? { id: mapping.id } : null; + } + return picked; + }; + + const filterGroupMembers = (where: GroupMemberWhere) => + store.groupMembers.filter((row) => { + if (typeof where.groupId === "string" && row.groupId !== where.groupId) + return false; + if ( + typeof where.groupId === "object" && + where.groupId !== null && + !where.groupId.in.includes(row.groupId) + ) + return false; + if (typeof where.userId === "string" && row.userId !== where.userId) + return false; + if ( + typeof where.userId === "object" && + where.userId !== null && + !where.userId.in.includes(row.userId) + ) + return false; + if (where.user) { + const user = store.users.find((u) => u.id === row.userId); + if (!user) return false; + const hit = where.user.OR.some((clause) => { + if (clause.email) + return user.email + .toLowerCase() + .includes(clause.email.contains.toLowerCase()); + if (clause.name) + return (user.name ?? "") + .toLowerCase() + .includes(clause.name.contains.toLowerCase()); + return false; + }); + if (!hit) return false; + } + return matchesKeyset(row, where.AND); + }); + + const findMember = (organizationId: string, userId: string) => + store.members.find( + (row) => row.organizationId === organizationId && row.userId === userId, + ); + + const filterOrgMembers = (where: OrgMemberWhere) => + store.members.filter((row) => { + if ( + where.organizationId !== undefined && + row.organizationId !== where.organizationId + ) + return false; + if (typeof where.userId === "string" && row.userId !== where.userId) + return false; + if ( + typeof where.userId === "object" && + where.userId !== null && + !where.userId.in.includes(row.userId) + ) + return false; + if (where.status !== undefined) { + const ok = + typeof where.status === "string" + ? row.status === where.status + : where.status.not === undefined || row.status !== where.status.not; + if (!ok) return false; + } + if (where.role !== undefined) { + const ok = + typeof where.role === "string" + ? row.role === where.role + : where.role.not === undefined || row.role !== where.role.not; + if (!ok) return false; + } + return true; + }); + + const filterMappings = (where: MappingWhere) => + store.roleMappings.filter( + (row) => + (where.id === undefined || row.id === where.id) && + (where.organizationId === undefined || + row.organizationId === where.organizationId) && + (where.groupId === undefined || row.groupId === where.groupId), + ); + + return { + Prisma: { JsonNull: null, PrismaClientKnownRequestError }, + db: { + apiKey: { + findUnique: async ({ where }: { where: { key?: string } }) => { + if (where.key === "oc_org_admin-key") + return { + userId: "user-admin", + organizationId: "org-1", + scope: "organization", + }; + // A PROJECT-scoped key owned by the org's OWNER: it authenticates + // fine, which is exactly why the router needs its own scope guard. + if (where.key === "oc_project-key-of-owner") + return { userId: "user-owner", projectId: "proj-1" }; + return null; + }, + findFirst: async () => null, + findMany: async () => [], + }, + user: { + findUnique: async ({ + where, + select, + }: { + where: { id?: string; externalAuthId?: string; email?: string }; + select?: Record; + }) => { + if (select?.organizationMemberships) { + return { + organizationMemberships: store.members + .filter((m) => m.userId === where.id) + .map((m) => ({ organizationId: m.organizationId })), + }; + } + return ( + store.users.find( + (u) => + (where.id !== undefined && u.id === where.id) || + (where.externalAuthId !== undefined && + u.externalAuthId === where.externalAuthId) || + (where.email !== undefined && u.email === where.email), + ) ?? null + ); + }, + }, + organizationMember: { + findUnique: async ({ + where, + }: { + where: { + organizationId_userId: { organizationId: string; userId: string }; + }; + }) => { + const { organizationId, userId } = where.organizationId_userId; + return findMember(organizationId, userId) ?? null; + }, + // The session auth path resolves membership through these — a stub + // returning null would read every session caller as org-less (401). + findFirst: async ({ + where, + }: { + where: { + organizationId?: string; + userId?: string; + status?: string | { not?: string }; + }; + }) => + store.members.find( + (row) => + (where.organizationId === undefined || + row.organizationId === where.organizationId) && + (where.userId === undefined || row.userId === where.userId) && + (where.status === undefined || + (typeof where.status === "string" + ? row.status === where.status + : where.status.not === undefined || + row.status !== where.status.not)), + ) ?? null, + // THE membership-validation query: { organizationId, userId: { in } } + // — plus the role-mapping apply's candidate read, which adds + // `role: { not: "owner" }` and selects role/userEmail. + findMany: async ({ + where, + select, + }: { + where: OrgMemberWhere; + select?: { userId?: boolean; role?: boolean; userEmail?: boolean }; + }) => + filterOrgMembers(where).map((row) => { + if (!select) return { ...row }; + const picked: Record = {}; + if (select.userId) picked.userId = row.userId; + if (select.role) picked.role = row.role; + if (select.userEmail) picked.userEmail = row.userEmail; + return picked; + }), + // The apply's role write. The `role: { not: "owner" }` predicate is + // honoured, or the "a mapping never demotes an owner" cases would be + // testing the mock rather than the service. + updateMany: async ({ + where, + data, + }: { + where: OrgMemberWhere; + data: { role: string }; + }) => { + const rows = filterOrgMembers(where); + for (const row of rows) row.role = data.role; + return { count: rows.length }; + }, + count: async () => 0, + }, + // Slice 5: the membership writers re-resolve group→role mappings, so + // the seam needs the mapping table to read. + groupRoleMapping: { + findFirst: async ({ where }: { where: MappingWhere }) => + filterMappings(where)[0] ?? null, + findMany: async ({ + where, + select, + }: { + where: MappingWhere; + select?: Record; + }) => + filterMappings(where) + .slice() + .sort( + (a, b) => + a.priority - b.priority || + a.createdAt.getTime() - b.createdAt.getTime() || + a.id.localeCompare(b.id), + ) + .map((row) => { + if (!select) return { ...row }; + const picked: Record = {}; + for (const key of [ + "id", + "organizationId", + "groupId", + "role", + "priority", + "createdAt", + "updatedAt", + ] as const) { + if (select[key]) picked[key] = row[key]; + } + return picked; + }), + }, + group: { + findFirst: async ({ + where, + select, + }: { + where: GroupWhere; + select?: GroupSelect; + }) => { + // Race simulation: the create pre-check (a name-keyed findFirst) + // misses, so the create itself must surface the P2002. + if (store.race && where.name !== undefined) return null; + const row = filterGroups(where)[0]; + return row ? pickGroup(row, select) : null; + }, + findMany: async ({ + where, + select, + take, + }: { + where: GroupWhere; + select?: GroupSelect; + take?: number; + }) => { + const rows = filterGroups(where) + .slice() + .sort( + (a, b) => + a.createdAt.getTime() - b.createdAt.getTime() || + a.id.localeCompare(b.id), + ); + const limited = take === undefined ? rows : rows.slice(0, take); + return limited.map((row) => pickGroup(row, select)); + }, + create: async ({ + data, + select, + }: { + data: { + organizationId: string; + name: string; + source: string; + externalId?: string | null; + }; + select?: GroupSelect; + }) => { + const dupe = store.groups.some( + (g) => + g.organizationId === data.organizationId && g.name === data.name, + ); + if (dupe) { + throw new PrismaClientKnownRequestError( + "Unique constraint failed", + "P2002", + ); + } + const row: GroupRow = { + id: `g-${++store.seq}`, + organizationId: data.organizationId, + name: data.name, + source: data.source, + externalId: data.externalId ?? null, + createdAt: new Date(), + updatedAt: new Date(), + }; + store.groups.push(row); + return pickGroup(row, select); + }, + // Org-scoped conditional write (the rename path): unique violations + // surface as P2002, a filter miss as count 0. + updateMany: async ({ + where, + data, + }: { + where: GroupWhere; + data: { name: string }; + }) => { + const rows = filterGroups(where); + for (const row of rows) { + const dupe = store.groups.some( + (g) => + g.organizationId === row.organizationId && + g.name === data.name && + g.id !== row.id, + ); + if (dupe) { + throw new PrismaClientKnownRequestError( + "Unique constraint failed", + "P2002", + ); + } + row.name = data.name; + row.updatedAt = new Date(); + } + return { count: rows.length }; + }, + // Delete applies the DB cascades the shipped migration declares: + // GroupMember, ProjectAccess group bindings, GroupRoleMapping. + deleteMany: async ({ where }: { where: GroupWhere }) => { + const rows = filterGroups(where); + for (const row of rows) { + store.groupMembers = store.groupMembers.filter( + (m) => m.groupId !== row.id, + ); + store.projectAccess = store.projectAccess.filter( + (pa) => pa.groupId !== row.id, + ); + store.roleMappings = store.roleMappings.filter( + (rm) => rm.groupId !== row.id, + ); + } + const ids = new Set(rows.map((r) => r.id)); + store.groups = store.groups.filter((g) => !ids.has(g.id)); + return { count: rows.length }; + }, + }, + groupMember: { + findUnique: async ({ + where, + }: { + where: { groupId_userId: { groupId: string; userId: string } }; + }) => { + const { groupId, userId } = where.groupId_userId; + const row = store.groupMembers.find( + (m) => m.groupId === groupId && m.userId === userId, + ); + return row ? { userId: row.userId } : null; + }, + findMany: async ({ + where, + select, + take, + }: { + where: GroupMemberWhere; + select?: { + groupId?: boolean; + userId?: boolean; + createdAt?: boolean; + user?: { select: { email?: boolean; name?: boolean } }; + }; + take?: number; + }) => { + const rows = filterGroupMembers(where) + .slice() + .sort( + (a, b) => + a.createdAt.getTime() - b.createdAt.getTime() || + a.userId.localeCompare(b.userId), + ); + const limited = take === undefined ? rows : rows.slice(0, take); + return limited.map((row) => { + if (!select) return { ...row }; + const picked: Record = {}; + if (select.groupId) picked.groupId = row.groupId; + if (select.userId) picked.userId = row.userId; + if (select.createdAt) picked.createdAt = row.createdAt; + if (select.user) { + const user = store.users.find((u) => u.id === row.userId); + picked.user = { + email: user?.email ?? "missing@example.com", + name: user?.name ?? null, + }; + } + return picked; + }); + }, + upsert: async ({ + where, + create, + }: { + where: { groupId_userId: { groupId: string; userId: string } }; + create: GroupMemberRow; + }) => { + const { groupId, userId } = where.groupId_userId; + const existing = store.groupMembers.find( + (m) => m.groupId === groupId && m.userId === userId, + ); + if (existing) return existing; + const row: GroupMemberRow = { ...create, createdAt: new Date() }; + store.groupMembers.push(row); + return row; + }, + createMany: async ({ + data, + }: { + data: { groupId: string; userId: string; createdByUserId: string }[]; + skipDuplicates?: boolean; + }) => { + let count = 0; + for (const d of data) { + const exists = store.groupMembers.some( + (m) => m.groupId === d.groupId && m.userId === d.userId, + ); + if (exists) continue; // skipDuplicates + store.groupMembers.push({ ...d, createdAt: new Date() }); + count++; + } + return { count }; + }, + deleteMany: async ({ where }: { where: GroupMemberWhere }) => { + const rows = filterGroupMembers(where); + const keys = new Set(rows.map((r) => `${r.groupId}:${r.userId}`)); + const before = store.groupMembers.length; + store.groupMembers = store.groupMembers.filter( + (m) => !keys.has(`${m.groupId}:${m.userId}`), + ); + return { count: before - store.groupMembers.length }; + }, + }, + project: { + findFirst: async () => ({ id: "proj-1", organizationId: "org-1" }), + findUnique: async () => ({ id: "proj-1", organizationId: "org-1" }), + }, + projectAccess: { findFirst: async () => null }, + auditLog: { + create: async ({ data }: { data: AuditRow }) => { + store.audits.push(data); + return data; + }, + }, + $transaction: async (ops: Promise[]) => { + store.txCount++; + return Promise.all(ops); + }, + }, + }; +}); + +import { createApiApp } from "../../app"; +import { registerOssOrgRoutes } from "./index"; +import { ossRoleResolver } from "../../services/org-role-resolver"; + +const sessionProvider = { + getSession: async () => { + const user = store.users.find((u) => u.id === store.sessionUserId); + return user ? { id: user.externalAuthId, email: user.email } : null; + }, +}; + +const app: Hono = createApiApp(sessionProvider, { + eeRoutes: registerOssOrgRoutes, + roleResolver: ossRoleResolver, +}); + +const at = (minutes: number) => new Date(Date.UTC(2026, 0, 1, 0, minutes)); + +const member = ( + userId: string, + role: string, + createdAt: Date, + organizationId = ORG, +): MemberRow => ({ + organizationId, + userId, + userEmail: `${userId}@example.com`, + role, + status: "active", + ssoExempt: false, + suspendedAt: null, + createdAt, +}); + +const group = ( + id: string, + name: string, + overrides: Partial = {}, +): GroupRow => ({ + id, + organizationId: ORG, + name, + source: "manual", + externalId: null, + createdAt: at(10), + updatedAt: at(10), + ...overrides, +}); + +beforeEach(() => { + store.users = [ + { + id: OWNER, + externalAuthId: "ext-owner", + email: "owner@example.com", + name: "Olive Owner", + }, + { + id: ADMIN, + externalAuthId: "ext-admin", + email: "admin@example.com", + name: "Adam Admin", + }, + { + id: MEMBER, + externalAuthId: "ext-member", + email: "member@elsewhere.test", + name: null, + }, + { + id: OUTSIDER, + externalAuthId: "ext-outsider", + email: "outsider@other.test", + name: "Odette Outsider", + }, + ]; + store.members = [ + member(OWNER, "owner", at(0)), + member(ADMIN, "admin", at(1)), + member(MEMBER, "member", at(2)), + member(OUTSIDER, "admin", at(3), OTHER_ORG), + ]; + store.groups = [ + group("g-a", "Engineering", { createdAt: at(10), updatedAt: at(10) }), + group("g-b", "Design", { createdAt: at(11), updatedAt: at(11) }), + group("g-scim", "Provisioned", { + source: "scim", + externalId: "idp-77", + createdAt: at(12), + updatedAt: at(12), + }), + // A group in a DIFFERENT org — never visible through this org's routes. + group("g-x", "Foreign", { organizationId: OTHER_ORG, createdAt: at(13) }), + ]; + store.groupMembers = [ + { + groupId: "g-a", + userId: OWNER, + createdByUserId: ADMIN, + createdAt: at(20), + }, + { + groupId: "g-a", + userId: ADMIN, + createdByUserId: ADMIN, + createdAt: at(21), + }, + { + groupId: "g-scim", + userId: MEMBER, + createdByUserId: null, + createdAt: at(22), + }, + // Membership of the foreign group, for cross-org isolation checks. + { + groupId: "g-x", + userId: OUTSIDER, + createdByUserId: null, + createdAt: at(23), + }, + ]; + store.projectAccess = [{ id: "pa-1", projectId: "proj-1", groupId: "g-a" }]; + // A CONVERGED baseline: `member` is the weakest role, so this mapping can + // never raise anyone and every existing membership case stays a no-op on + // the Slice 5 seam. Cases that need a real apply promote it to "admin". + store.roleMappings = [ + { + id: "rm-1", + organizationId: ORG, + groupId: "g-a", + role: "member", + priority: 0, + createdAt: at(30), + updatedAt: at(30), + }, + ]; + store.audits = []; + store.seq = 100; + store.txCount = 0; + store.race = false; + store.sessionUserId = null; +}); + +const groupRow = (id: string) => store.groups.find((g) => g.id === id); +const membersOf = (groupId: string) => + store.groupMembers + .filter((m) => m.groupId === groupId) + .map((m) => m.userId) + .sort(); + +const asAdmin = { headers: { Authorization: `Bearer ${ADMIN_KEY}` } }; +const asProjectKey = { headers: { Authorization: `Bearer ${PROJECT_KEY}` } }; + +interface GroupListBody { + data: { + id: string; + name: string; + source: string; + externalId: string | null; + memberCount: number; + createdAt: string; + updatedAt: string; + }[]; + nextCursor: string | null; +} + +interface MemberListBody { + data: { + userId: string; + email: string; + name: string | null; + addedAt: string; + }[]; + nextCursor: string | null; +} + +const list = async (query = ""): Promise => { + const res = await app.request(`/v1/org/groups${query}`, asAdmin); + expect(res.status).toBe(200); + return (await res.json()) as GroupListBody; +}; + +const create = (body: unknown, init: RequestInit = asAdmin) => + app.request("/v1/org/groups", { + ...init, + method: "POST", + body: JSON.stringify(body), + }); + +const rename = (id: string, body: unknown, init: RequestInit = asAdmin) => + app.request(`/v1/org/groups/${id}`, { + ...init, + method: "PATCH", + body: JSON.stringify(body), + }); + +const remove = (id: string, init: RequestInit = asAdmin) => + app.request(`/v1/org/groups/${id}`, { ...init, method: "DELETE" }); + +const putMembers = (id: string, body: unknown, init: RequestInit = asAdmin) => + app.request(`/v1/org/groups/${id}/members`, { + ...init, + method: "PUT", + body: JSON.stringify(body), + }); + +const putMember = (id: string, userId: string, init: RequestInit = asAdmin) => + app.request(`/v1/org/groups/${id}/members/${userId}`, { + ...init, + method: "PUT", + body: JSON.stringify({}), + }); + +const deleteMember = ( + id: string, + userId: string, + init: RequestInit = asAdmin, +) => + app.request(`/v1/org/groups/${id}/members/${userId}`, { + ...init, + method: "DELETE", + }); + +describe("GET /v1/org/groups", () => { + it("returns the org's groups in the page envelope with member counts", async () => { + const body = await list(); + expect(body.nextCursor).toBeNull(); + expect(body.data.map((row) => row.id)).toEqual(["g-a", "g-b", "g-scim"]); + expect(body.data[0]).toEqual({ + id: "g-a", + name: "Engineering", + source: "manual", + externalId: null, + memberCount: 2, + createdAt: at(10).toISOString(), + updatedAt: at(10).toISOString(), + }); + expect(body.data[2]).toMatchObject({ + source: "scim", + externalId: "idp-77", + memberCount: 1, + }); + }); + + it("never leaks groups of another organization", async () => { + const body = await list(); + expect(body.data.some((row) => row.id === "g-x")).toBe(false); + }); + + it("filters by source", async () => { + const body = await list("?source=scim"); + expect(body.data.map((r) => r.id)).toEqual(["g-scim"]); + const manual = await list("?source=manual"); + expect(manual.data.map((r) => r.id)).toEqual(["g-a", "g-b"]); + }); + + it("rejects an unknown source with 422", async () => { + const res = await app.request("/v1/org/groups?source=github", asAdmin); + expect(res.status).toBe(422); + }); + + it("filters by free-text q over name, case-insensitively", async () => { + const body = await list("?q=ENGINEER"); + expect(body.data.map((r) => r.id)).toEqual(["g-a"]); + }); + + it("pages with an opaque cursor and ends with nextCursor null", async () => { + const first = await list("?limit=2"); + expect(first.data.map((r) => r.id)).toEqual(["g-a", "g-b"]); + expect(first.nextCursor).toBeTruthy(); + + const second = await list( + `?limit=2&cursor=${encodeURIComponent(first.nextCursor ?? "")}`, + ); + expect(second.data.map((r) => r.id)).toEqual(["g-scim"]); + expect(second.nextCursor).toBeNull(); + }); + + it("walks every page exactly once when createdAt ties", async () => { + // Same millisecond for all: only the id half of the cursor can separate + // them, so a one-at-a-time walk is the tiebreak's real test. + for (const row of store.groups) row.createdAt = at(7); + + const seen: string[] = []; + let cursor: string | null = null; + for (let page = 0; page < 10; page++) { + const body: GroupListBody = await list( + `?limit=1${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`, + ); + seen.push(...body.data.map((r) => r.id)); + cursor = body.nextCursor; + if (!cursor) break; + } + + expect(cursor).toBeNull(); + expect(seen.slice().sort()).toEqual(["g-a", "g-b", "g-scim"]); + expect(new Set(seen).size).toBe(seen.length); + }); + + it("treats a malformed cursor as the first page instead of failing", async () => { + const body = await list("?cursor=not-a-real-cursor"); + expect(body.data).toHaveLength(3); + }); + + it("rejects out-of-range limits with 422", async () => { + for (const limit of ["5000", "0", "abc"]) { + const res = await app.request(`/v1/org/groups?limit=${limit}`, asAdmin); + expect(res.status).toBe(422); + } + }); + + it("403s a project-scoped key even when its user is an org owner", async () => { + const res = await app.request("/v1/org/groups", asProjectKey); + expect(res.status).toBe(403); + }); + + it("403s a non-admin member (deterministic, not a 401)", async () => { + store.sessionUserId = MEMBER; + const res = await app.request("/v1/org/groups"); + expect(res.status).toBe(403); + }); + + it("rejects a suspended admin's org key (suspended reads as no role)", async () => { + const row = store.members.find((m) => m.userId === ADMIN); + if (row) row.status = "suspended"; + const res = await app.request("/v1/org/groups", asAdmin); + expect(res.status).toBe(401); + }); + + it("401s an unauthenticated caller", async () => { + const res = await app.request("/v1/org/groups"); + expect(res.status).toBe(401); + }); +}); + +describe("POST /v1/org/groups", () => { + it("creates a manual group and audits it", async () => { + const res = await create({ name: "Platform" }); + expect(res.status).toBe(200); + const body = (await res.json()) as GroupListBody["data"][number]; + expect(body).toMatchObject({ + name: "Platform", + source: "manual", + externalId: null, + memberCount: 0, + }); + expect(store.audits).toHaveLength(1); + expect(store.audits[0]).toMatchObject({ + organizationId: ORG, + userId: ADMIN, + action: "create", + service: "group", + source: "api", + metadata: { groupId: body.id, name: "Platform" }, + }); + }); + + it("ignores body source/externalId: creates are always manual", async () => { + const res = await create({ + name: "Sneaky", + source: "scim", + externalId: "idp-evil", + }); + expect(res.status).toBe(200); + const body = (await res.json()) as GroupListBody["data"][number]; + expect(body.source).toBe("manual"); + expect(body.externalId).toBeNull(); + expect(groupRow(body.id)?.source).toBe("manual"); + }); + + it("trims the name before storing", async () => { + const res = await create({ name: " Padded " }); + expect(res.status).toBe(200); + const body = (await res.json()) as GroupListBody["data"][number]; + expect(body.name).toBe("Padded"); + }); + + it("422s an empty / whitespace-only / overlong / missing name", async () => { + for (const body of [ + { name: "" }, + { name: " " }, + { name: "x".repeat(101) }, + {}, + ]) { + const res = await create(body); + expect(res.status).toBe(422); + } + expect(store.audits).toHaveLength(0); + }); + + it("422s a missing/unparseable body", async () => { + const res = await app.request("/v1/org/groups", { + ...asAdmin, + method: "POST", + }); + expect(res.status).toBe(422); + }); + + it("409s a duplicate name and audits nothing", async () => { + const res = await create({ name: "Engineering" }); + expect(res.status).toBe(409); + expect(store.audits).toHaveLength(0); + expect(store.groups.filter((g) => g.name === "Engineering")).toHaveLength( + 1, + ); + }); + + it("409s a create-create race surfaced as P2002", async () => { + store.race = true; + const res = await create({ name: "Engineering" }); + expect(res.status).toBe(409); + expect(store.audits).toHaveLength(0); + }); + + it("403s a project-scoped key and audits/creates nothing", async () => { + const res = await create({ name: "Platform" }, asProjectKey); + expect(res.status).toBe(403); + expect(store.audits).toHaveLength(0); + expect(store.groups.some((g) => g.name === "Platform")).toBe(false); + }); + + it("403s a non-admin member and audits nothing", async () => { + store.sessionUserId = MEMBER; + const res = await create({ name: "Platform" }, {}); + expect(res.status).toBe(403); + expect(store.audits).toHaveLength(0); + }); +}); + +describe("PATCH /v1/org/groups/:groupId", () => { + it("renames a group and audits the change discriminator", async () => { + const res = await rename("g-a", { name: "Core Engineering" }); + expect(res.status).toBe(200); + const body = (await res.json()) as GroupListBody["data"][number]; + expect(body).toMatchObject({ + id: "g-a", + name: "Core Engineering", + memberCount: 2, + }); + expect(groupRow("g-a")?.name).toBe("Core Engineering"); + expect(store.audits).toHaveLength(1); + expect(store.audits[0]).toMatchObject({ + action: "update", + service: "group", + metadata: { groupId: "g-a", change: "name", name: "Core Engineering" }, + }); + }); + + it("permits a rename-to-self as a no-op 200", async () => { + const res = await rename("g-a", { name: "Engineering" }); + expect(res.status).toBe(200); + expect(groupRow("g-a")?.name).toBe("Engineering"); + }); + + it("409s a rename onto another group's name", async () => { + const res = await rename("g-a", { name: "Design" }); + expect(res.status).toBe(409); + expect(groupRow("g-a")?.name).toBe("Engineering"); + expect(store.audits).toHaveLength(0); + }); + + it("404s an unknown group", async () => { + const res = await rename("g-nope", { name: "Anything" }); + expect(res.status).toBe(404); + }); + + it("404s a group of another organization (cross-org isolation)", async () => { + const res = await rename("g-x", { name: "Captured" }); + expect(res.status).toBe(404); + expect(groupRow("g-x")?.name).toBe("Foreign"); + }); + + it("409s a scim-provisioned group (IdP-owned)", async () => { + const res = await rename("g-scim", { name: "Mine now" }); + expect(res.status).toBe(409); + expect(groupRow("g-scim")?.name).toBe("Provisioned"); + expect(store.audits).toHaveLength(0); + }); + + it("422s an invalid name", async () => { + const res = await rename("g-a", { name: " " }); + expect(res.status).toBe(422); + }); +}); + +describe("DELETE /v1/org/groups/:groupId", () => { + it("deletes, reports the impact read BEFORE the delete, and cascades", async () => { + const res = await remove("g-a"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + id: "g-a", + name: "Engineering", + removedMembers: 2, + removedProjectBindings: 1, + removedRoleMappings: 1, + }); + // Cascades applied: membership, project bindings, and the role mapping + // went with the group. + expect(groupRow("g-a")).toBeUndefined(); + expect(membersOf("g-a")).toEqual([]); + expect(store.projectAccess.some((pa) => pa.groupId === "g-a")).toBe(false); + expect(store.roleMappings.some((rm) => rm.groupId === "g-a")).toBe(false); + }); + + it("audits counts only — never id arrays", async () => { + const res = await remove("g-a"); + expect(res.status).toBe(200); + expect(store.audits).toHaveLength(1); + expect(store.audits[0]).toMatchObject({ + action: "delete", + service: "group", + metadata: { + groupId: "g-a", + name: "Engineering", + removedMembers: 2, + removedProjectBindings: 1, + removedRoleMappings: 1, + }, + }); + for (const value of Object.values(store.audits[0]?.metadata ?? {})) { + expect(Array.isArray(value)).toBe(false); + } + }); + + it("404s an unknown group and audits nothing", async () => { + const res = await remove("g-nope"); + expect(res.status).toBe(404); + expect(store.audits).toHaveLength(0); + }); + + it("404s a group of another organization (cross-org isolation)", async () => { + const res = await remove("g-x"); + expect(res.status).toBe(404); + expect(groupRow("g-x")).toBeTruthy(); + }); + + it("409s a scim-provisioned group", async () => { + const res = await remove("g-scim"); + expect(res.status).toBe(409); + expect(groupRow("g-scim")).toBeTruthy(); + }); + + it("403s a project-scoped key and deletes nothing", async () => { + const res = await remove("g-a", asProjectKey); + expect(res.status).toBe(403); + expect(groupRow("g-a")).toBeTruthy(); + expect(store.audits).toHaveLength(0); + }); +}); + +describe("GET /v1/org/groups/:groupId/members", () => { + const listMembers = async ( + groupId: string, + query = "", + ): Promise => { + const res = await app.request( + `/v1/org/groups/${groupId}/members${query}`, + asAdmin, + ); + expect(res.status).toBe(200); + return (await res.json()) as MemberListBody; + }; + + it("returns the group's members with user identity joined in", async () => { + const body = await listMembers("g-a"); + expect(body.nextCursor).toBeNull(); + expect(body.data).toEqual([ + { + userId: OWNER, + email: "owner@example.com", + name: "Olive Owner", + addedAt: at(20).toISOString(), + }, + { + userId: ADMIN, + email: "admin@example.com", + name: "Adam Admin", + addedAt: at(21).toISOString(), + }, + ]); + }); + + it("filters by q over email and name, case-insensitively", async () => { + const byEmail = await listMembers("g-a", "?q=OWNER@example"); + expect(byEmail.data.map((r) => r.userId)).toEqual([OWNER]); + const byName = await listMembers("g-a", "?q=adam"); + expect(byName.data.map((r) => r.userId)).toEqual([ADMIN]); + }); + + it("pages the member list with the two-part cursor", async () => { + const first = await listMembers("g-a", "?limit=1"); + expect(first.data.map((r) => r.userId)).toEqual([OWNER]); + expect(first.nextCursor).toBeTruthy(); + const second = await listMembers( + "g-a", + `?limit=1&cursor=${encodeURIComponent(first.nextCursor ?? "")}`, + ); + expect(second.data.map((r) => r.userId)).toEqual([ADMIN]); + expect(second.nextCursor).toBeNull(); + }); + + it("404s a group of another organization (no membership oracle)", async () => { + const res = await app.request("/v1/org/groups/g-x/members", asAdmin); + expect(res.status).toBe(404); + }); + + it("lists a scim group's members (reads are always allowed)", async () => { + const body = await listMembers("g-scim"); + expect(body.data.map((r) => r.userId)).toEqual([MEMBER]); + }); +}); + +describe("PUT /v1/org/groups/:groupId/members (replace-set)", () => { + it("applies the exact set: adds, removes, keeps, and returns the delta", async () => { + // g-a currently {OWNER, ADMIN}; target {ADMIN, MEMBER}. + const res = await putMembers("g-a", { userIds: [ADMIN, MEMBER] }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ added: 1, removed: 1 }); + expect(membersOf("g-a")).toEqual([ADMIN, MEMBER].sort()); + expect(store.txCount).toBe(1); + expect(store.audits).toHaveLength(1); + expect(store.audits[0]).toMatchObject({ + action: "update", + service: "group", + source: "api", + metadata: { groupId: "g-a", change: "members", added: 1, removed: 1 }, + }); + // Counts only, never id arrays. + for (const value of Object.values(store.audits[0]?.metadata ?? {})) { + expect(Array.isArray(value)).toBe(false); + } + }); + + it("an empty set clears the group", async () => { + const res = await putMembers("g-a", { userIds: [] }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ added: 0, removed: 2 }); + expect(membersOf("g-a")).toEqual([]); + }); + + it("a no-op set returns {0,0} without opening a transaction", async () => { + const res = await putMembers("g-a", { userIds: [OWNER, ADMIN] }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ added: 0, removed: 0 }); + expect(store.txCount).toBe(0); + expect(membersOf("g-a")).toEqual([ADMIN, OWNER].sort()); + }); + + it("deduplicates repeated ids in the payload", async () => { + const res = await putMembers("g-b", { userIds: [MEMBER, MEMBER] }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ added: 1, removed: 0 }); + expect(membersOf("g-b")).toEqual([MEMBER]); + }); + + it("422s a set beyond the cap", async () => { + const userIds = Array.from({ length: 1001 }, (_, i) => `u-${i}`); + const res = await putMembers("g-a", { userIds }); + expect(res.status).toBe(422); + }); + + it("400s when ANY id is not a member of this org — the security core", async () => { + const res = await putMembers("g-a", { userIds: [ADMIN, OUTSIDER] }); + expect(res.status).toBe(400); + // Nothing written, nothing audited: the whole write is rejected. + expect(membersOf("g-a")).toEqual([ADMIN, OWNER].sort()); + expect(store.audits).toHaveLength(0); + expect(store.txCount).toBe(0); + }); + + it("allows suspended members (suspension is an auth-time gate)", async () => { + const row = store.members.find((m) => m.userId === MEMBER); + if (row) row.status = "suspended"; + const res = await putMembers("g-b", { userIds: [MEMBER] }); + expect(res.status).toBe(200); + expect(membersOf("g-b")).toEqual([MEMBER]); + }); + + it("404s a cross-org group before validating membership", async () => { + const res = await putMembers("g-x", { userIds: [OUTSIDER] }); + expect(res.status).toBe(404); + expect(membersOf("g-x")).toEqual([OUTSIDER]); + }); + + it("409s a scim group (membership is IdP-owned)", async () => { + const res = await putMembers("g-scim", { userIds: [ADMIN] }); + expect(res.status).toBe(409); + expect(membersOf("g-scim")).toEqual([MEMBER]); + }); + + it("422s a malformed body", async () => { + for (const body of [{}, { userIds: "ADMIN" }, { userIds: [""] }, null]) { + const res = await putMembers("g-a", body); + expect(res.status).toBe(422); + } + }); + + it("403s a non-admin and writes/audits nothing", async () => { + store.sessionUserId = MEMBER; + const res = await putMembers("g-a", { userIds: [] }, {}); + expect(res.status).toBe(403); + expect(membersOf("g-a")).toEqual([ADMIN, OWNER].sort()); + expect(store.audits).toHaveLength(0); + }); +}); + +describe("PUT /v1/org/groups/:groupId/members/:userId (single add)", () => { + it("adds a member, returns a JSON body, and audits", async () => { + const res = await putMember("g-b", MEMBER); + expect(res.status).toBe(200); + // apiPut ALWAYS parses the response — a 204 here would break the client. + expect(res.headers.get("content-type")).toContain("application/json"); + expect(await res.json()).toEqual({ added: true }); + expect(membersOf("g-b")).toEqual([MEMBER]); + expect(store.audits).toHaveLength(1); + expect(store.audits[0]).toMatchObject({ + metadata: { + groupId: "g-b", + change: "members", + userId: MEMBER, + added: true, + }, + }); + }); + + it("is idempotent: a second add reports added: false", async () => { + expect((await putMember("g-b", MEMBER)).status).toBe(200); + const res = await putMember("g-b", MEMBER); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ added: false }); + expect(membersOf("g-b")).toEqual([MEMBER]); + }); + + it("400s a user from another organization", async () => { + const res = await putMember("g-b", OUTSIDER); + expect(res.status).toBe(400); + expect(membersOf("g-b")).toEqual([]); + expect(store.audits).toHaveLength(0); + }); + + it("404s a cross-org group / 409s a scim group", async () => { + expect((await putMember("g-x", MEMBER)).status).toBe(404); + expect((await putMember("g-scim", ADMIN)).status).toBe(409); + }); +}); + +describe("DELETE /v1/org/groups/:groupId/members/:userId (single remove)", () => { + it("removes a member and audits", async () => { + const res = await deleteMember("g-a", OWNER); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ removed: true }); + expect(membersOf("g-a")).toEqual([ADMIN]); + expect(store.audits).toHaveLength(1); + expect(store.audits[0]).toMatchObject({ + metadata: { + groupId: "g-a", + change: "members", + userId: OWNER, + removed: true, + }, + }); + }); + + it("is idempotent: a missing membership is removed:false, not 404", async () => { + const res = await deleteMember("g-b", MEMBER); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ removed: false }); + }); + + it("404s only for a missing/cross-org GROUP", async () => { + expect((await deleteMember("g-nope", MEMBER)).status).toBe(404); + expect((await deleteMember("g-x", OUTSIDER)).status).toBe(404); + expect(membersOf("g-x")).toEqual([OUTSIDER]); + }); + + it("409s a scim group", async () => { + const res = await deleteMember("g-scim", MEMBER); + expect(res.status).toBe(409); + expect(membersOf("g-scim")).toEqual([MEMBER]); + }); +}); + +// ── Slice 5 seam: group→role mappings ─────────────────────────────────── +// +// The membership writers re-resolve mapped org roles after their write. The +// contract they must honour is decision C: a mapping is a FLOOR — it can +// RAISE a member's org role and can never lower one. +describe("the role-mapping seam", () => { + const mapGroupToAdmin = (groupId: string) => { + store.roleMappings = [ + { + id: "rm-admin", + organizationId: ORG, + groupId, + role: "admin", + priority: 0, + createdAt: at(30), + updatedAt: at(30), + }, + ]; + }; + + const roleOf = (userId: string) => + store.members.find((m) => m.organizationId === ORG && m.userId === userId) + ?.role; + + const memberAudits = () => store.audits.filter((a) => a.service === "member"); + + it("raises a user added to an admin-mapped group, alongside the GROUP audit", async () => { + mapGroupToAdmin("g-a"); + const res = await putMembers("g-a", { userIds: [OWNER, ADMIN, MEMBER] }); + expect(res.status).toBe(200); + expect(roleOf(MEMBER)).toBe("admin"); + // The owner is untouchable and the acting admin is skipped. + expect(roleOf(OWNER)).toBe("owner"); + + expect(memberAudits()).toHaveLength(1); + expect(memberAudits()[0]).toMatchObject({ + organizationId: ORG, + userId: ADMIN, + action: "update", + service: "member", + source: "api", + metadata: { + targetUserId: MEMBER, + role: "admin", + previousRole: "member", + via: "role-mapping", + mappingId: "rm-admin", + groupId: "g-a", + trigger: "membership", + }, + }); + expect(store.audits.filter((a) => a.service === "group")).toHaveLength(1); + }); + + it("changes no roles when the group has no mapping of its own", async () => { + mapGroupToAdmin("g-a"); + const res = await putMembers("g-b", { userIds: [MEMBER] }); + expect(res.status).toBe(200); + expect(roleOf(MEMBER)).toBe("member"); + expect(memberAudits()).toHaveLength(0); + }); + + it("does NOT demote when a user is removed from an admin-mapped group", async () => { + mapGroupToAdmin("g-a"); + expect((await putMember("g-a", MEMBER)).status).toBe(200); + expect(roleOf(MEMBER)).toBe("admin"); + + store.audits = []; + const res = await deleteMember("g-a", MEMBER); + expect(res.status).toBe(200); + // The grant sticks: only PATCH /v1/org/members/:userId can lower a role. + expect(roleOf(MEMBER)).toBe("admin"); + expect(memberAudits()).toHaveLength(0); + }); + + // The removal paths feed the ids they just removed back into the apply, so a + // user who was being SHADOWED by this group's mapping is re-resolved against + // the mappings that still cover them. Without that they are absent from the + // post-write member read and stay under-privileged until some unrelated + // write happens to converge them. + const shadowThenAdmin = () => { + store.roleMappings = [ + { + id: "rm-shadow", + organizationId: ORG, + groupId: "g-a", + role: "member", + priority: 0, + createdAt: at(30), + updatedAt: at(30), + }, + { + id: "rm-admin", + organizationId: ORG, + groupId: "g-b", + role: "admin", + priority: 1, + createdAt: at(31), + updatedAt: at(31), + }, + ]; + store.groupMembers.push( + { + groupId: "g-a", + userId: MEMBER, + createdByUserId: ADMIN, + createdAt: at(24), + }, + { + groupId: "g-b", + userId: MEMBER, + createdByUserId: ADMIN, + createdAt: at(25), + }, + ); + }; + + it("UNSHADOWS a single remove: leaving the member-mapped group raises to admin", async () => { + shadowThenAdmin(); + expect(roleOf(MEMBER)).toBe("member"); + + const res = await deleteMember("g-a", MEMBER); + expect(res.status).toBe(200); + expect(roleOf(MEMBER)).toBe("admin"); + expect(memberAudits()).toHaveLength(1); + expect(memberAudits()[0]).toMatchObject({ + metadata: { + targetUserId: MEMBER, + role: "admin", + previousRole: "member", + via: "role-mapping", + mappingId: "rm-admin", + groupId: "g-b", + trigger: "membership", + }, + }); + }); + + it("UNSHADOWS a replace-set that DROPS a user, down to the last member", async () => { + shadowThenAdmin(); + // g-a keeps OWNER + ADMIN, neither of which the apply may touch — the + // dropped MEMBER is the only candidate, and only because the writer hands + // their id over. + const res = await putMembers("g-a", { userIds: [OWNER, ADMIN] }); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ removed: 1 }); + expect(roleOf(MEMBER)).toBe("admin"); + + // Again with the group emptied entirely: the removed ids are what keep the + // apply from short-circuiting on "this group has no members left". + const row = store.members.find((m) => m.userId === MEMBER); + if (row) row.role = "member"; + store.groupMembers.push({ + groupId: "g-a", + userId: MEMBER, + createdByUserId: ADMIN, + createdAt: at(26), + }); + const cleared = await putMembers("g-a", { userIds: [] }); + expect(cleared.status).toBe(200); + expect(membersOf("g-a")).toEqual([]); + expect(roleOf(MEMBER)).toBe("admin"); + }); + + it("a no-delta replace-set opens no transaction and changes no roles", async () => { + mapGroupToAdmin("g-a"); + const res = await putMembers("g-a", { userIds: [OWNER, ADMIN] }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ added: 0, removed: 0 }); + expect(store.txCount).toBe(0); + expect(memberAudits()).toHaveLength(0); + }); + + it("deleting a mapped group cascades the mapping but never reverts roles", async () => { + mapGroupToAdmin("g-a"); + expect((await putMember("g-a", MEMBER)).status).toBe(200); + expect(roleOf(MEMBER)).toBe("admin"); + + store.audits = []; + const res = await remove("g-a"); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ removedRoleMappings: 1 }); + expect(store.roleMappings).toHaveLength(0); + expect(roleOf(MEMBER)).toBe("admin"); + expect(memberAudits()).toHaveLength(0); + }); +}); diff --git a/packages/api/src/routes/org/groups.ts b/packages/api/src/routes/org/groups.ts new file mode 100644 index 00000000..f931e055 --- /dev/null +++ b/packages/api/src/routes/org/groups.ts @@ -0,0 +1,221 @@ +import { Hono } from "hono"; +import type { Context } from "hono"; +import type { ApiEnv } from "../../types"; +import { auth } from "../../middleware/auth"; +import { ServiceError } from "../../services/errors"; +import { parse } from "./parse"; +import { + addOrgGroupMember, + createOrgGroup, + deleteOrgGroup, + listOrgGroupMembers, + listOrgGroups, + removeOrgGroupMember, + renameOrgGroup, + setOrgGroupMembers, +} from "../../services/org-group-service"; +import { + createGroupSchema, + directoryListQuerySchema, + groupListQuerySchema, + renameGroupSchema, + setGroupMembersSchema, +} from "../../validations/org"; +import { + withAudit, + AUDIT_ACTIONS, + AUDIT_SERVICES, + AUDIT_SOURCE, +} from "../../services/audit-service"; + +/** + * `/v1/org/groups` — the organization's human groups. + * + * Same guard stack as `/v1/org/members`, for the same reasons: + * + * `requireProject: false`: these are ORG-scoped routes, so a caller with no + * project context (an org API key without `X-Project-Id`) must still get + * through. `role: "admin"` makes the whole router admin-only — a plain member + * gets a deterministic 403, which is exactly what the web client expects + * (directory queries are not retried on 403). Both only work because the OSS + * edition now registers a `RoleResolver`. + * + * `role` alone is SCOPE-BLIND, so it is not sufficient on its own: a + * project-scoped key (the credential an agent carries) resolves to its owning + * user, and if that user happens to be an org admin the role check passes. A + * leaked agent key would then be able to rewrite group membership — the very + * substrate project access and policy identities hang off. Org-wide authority + * requires an org-wide credential, so project-scoped callers are rejected + * outright. + */ +export const orgGroupRoutes = () => { + const app = new Hono(); + app.use("*", auth({ requireProject: false, role: "admin" })); + app.use("*", async (c, next) => { + if (c.get("auth").scope === "project") { + throw new ServiceError( + "FORBIDDEN", + "Organization management requires an organization-scoped credential.", + ); + } + return next(); + }); + + // `organizationId` in every audit params below is deliberate: besides + // scoping the audit row it flushes the gateway's org cache + // (invalidateGatewayCacheForOrg). Group membership is exactly what the + // gateway's principal resolution reads — a missed flush becomes a stale + // authorization decision, so EVERY membership write must go through withAudit. + const auditBase = (c: Context) => ({ + organizationId: c.get("auth").organizationId, + userId: c.get("auth").userId, + userEmail: c.get("auth").userEmail, + service: AUDIT_SERVICES.GROUP, + source: AUDIT_SOURCE.API, + }); + + // GET /org/groups — cursor-paged, optionally filtered by source / free text. + app.get("/", async (c) => { + const auth = c.get("auth"); + const query = parse(groupListQuerySchema, c.req.query()); + return c.json(await listOrgGroups(auth.organizationId, query)); + }); + + // POST /org/groups — create a manual group. + app.post("/", async (c) => { + const auth = c.get("auth"); + const body = await c.req.json().catch(() => null); + const input = parse(createGroupSchema, body); + + const group = await withAudit( + () => createOrgGroup(auth.organizationId, input.name), + (created) => ({ + ...auditBase(c), + action: AUDIT_ACTIONS.CREATE, + metadata: { groupId: created.id, name: created.name }, + }), + ); + return c.json(group); + }); + + // PATCH /org/groups/:groupId — rename. + app.patch("/:groupId", async (c) => { + const auth = c.get("auth"); + const groupId = c.req.param("groupId"); + const body = await c.req.json().catch(() => null); + const input = parse(renameGroupSchema, body); + + const group = await withAudit( + () => renameOrgGroup(auth.organizationId, groupId, input.name), + (renamed) => ({ + ...auditBase(c), + action: AUDIT_ACTIONS.UPDATE, + metadata: { groupId: renamed.id, change: "name", name: renamed.name }, + }), + ); + return c.json(group); + }); + + // DELETE /org/groups/:groupId — the response carries the cascade impact + // (read before the delete) so the UI can report what went with the group. + app.delete("/:groupId", async (c) => { + const auth = c.get("auth"); + const groupId = c.req.param("groupId"); + + const result = await withAudit( + () => deleteOrgGroup(auth.organizationId, auth.userId, groupId), + (deleted) => ({ + ...auditBase(c), + action: AUDIT_ACTIONS.DELETE, + // Counts only, never id arrays — audit metadata must stay bounded. + metadata: { + groupId: deleted.id, + name: deleted.name, + removedMembers: deleted.removedMembers, + removedProjectBindings: deleted.removedProjectBindings, + removedRoleMappings: deleted.removedRoleMappings, + }, + }), + ); + return c.json(result); + }); + + // GET /org/groups/:groupId/members — cursor-paged member list. + app.get("/:groupId/members", async (c) => { + const auth = c.get("auth"); + const groupId = c.req.param("groupId"); + const query = parse(directoryListQuerySchema, c.req.query()); + return c.json( + await listOrgGroupMembers(auth.organizationId, groupId, query), + ); + }); + + // PUT /org/groups/:groupId/members — bulk replace-set (the dialog's save). + // Every PUT returns a JSON body: the client's apiPut ALWAYS parses, so a + // 204 here would throw in the browser. + app.put("/:groupId/members", async (c) => { + const auth = c.get("auth"); + const groupId = c.req.param("groupId"); + const body = await c.req.json().catch(() => null); + const input = parse(setGroupMembersSchema, body); + + const result = await withAudit( + () => + setOrgGroupMembers( + auth.organizationId, + auth.userId, + groupId, + input.userIds, + ), + (delta) => ({ + ...auditBase(c), + action: AUDIT_ACTIONS.UPDATE, + metadata: { + groupId, + change: "members", + added: delta.added, + removed: delta.removed, + }, + }), + ); + return c.json(result); + }); + + // PUT /org/groups/:groupId/members/:userId — idempotent single add. + app.put("/:groupId/members/:userId", async (c) => { + const auth = c.get("auth"); + const groupId = c.req.param("groupId"); + const userId = c.req.param("userId"); + + const result = await withAudit( + () => + addOrgGroupMember(auth.organizationId, auth.userId, groupId, userId), + (r) => ({ + ...auditBase(c), + action: AUDIT_ACTIONS.UPDATE, + metadata: { groupId, change: "members", userId, added: r.added }, + }), + ); + return c.json(result); + }); + + // DELETE /org/groups/:groupId/members/:userId — idempotent single remove. + app.delete("/:groupId/members/:userId", async (c) => { + const auth = c.get("auth"); + const groupId = c.req.param("groupId"); + const userId = c.req.param("userId"); + + const result = await withAudit( + () => + removeOrgGroupMember(auth.organizationId, auth.userId, groupId, userId), + (r) => ({ + ...auditBase(c), + action: AUDIT_ACTIONS.UPDATE, + metadata: { groupId, change: "members", userId, removed: r.removed }, + }), + ); + return c.json(result); + }); + + return app; +}; diff --git a/packages/api/src/routes/org/index.ts b/packages/api/src/routes/org/index.ts index 1cbcbff7..d14c5b6c 100644 --- a/packages/api/src/routes/org/index.ts +++ b/packages/api/src/routes/org/index.ts @@ -2,9 +2,12 @@ import type { Hono } from "hono"; import type { ApiEnv } from "../../types"; import { orgMemberRoutes } from "./members"; import { orgInvitationRoutes } from "./invitations"; +import { orgGroupRoutes } from "./groups"; +import { orgRoleMappingRoutes } from "./role-mappings"; +import { ossProjectRoutes } from "./projects"; /** - * The OSS edition's `/v1/org/*` surface. + * The OSS edition's EDITION SURFACE: `/v1/org/*` PLUS `/v1/projects/*`. * * OSS-ONLY BY CONSTRUCTION. This is never registered in the shared * `createApiApp` route table: it is mounted through @@ -13,12 +16,22 @@ import { orgInvitationRoutes } from "./invitations"; * replaces with its own org router. Registering here rather than in `app.ts` * keeps the shared file free of edition-specific routes (upstream-merge * collisions) and avoids Hono's first-registration-wins silently shadowing an - * EE route with an OSS one. + * EE route with an OSS one — which is exactly why project administration is + * registered here too, even though its URL is not under `/org`. The exported + * name stays `registerOssOrgRoutes`: renaming it buys nothing and touches the + * init seam plus every route test. * - * Later org slices (invitations, groups, role mappings) append their - * `app.route(...)` line here. + * Mounting a sub-app re-registers its `use("*")` guards under the mount path, + * so each sub-app's guard stack covers every path beneath it — and only those. + * `/projects` therefore owns the whole `/v1/projects/*` namespace in OSS; do + * not register a second router there. + * + * Later org slices (role mappings, …) append their `app.route(...)` line here. */ export const registerOssOrgRoutes = (app: Hono) => { app.route("/org/members", orgMemberRoutes()); app.route("/org/invitations", orgInvitationRoutes()); + app.route("/org/groups", orgGroupRoutes()); + app.route("/org/role-mappings", orgRoleMappingRoutes()); + app.route("/projects", ossProjectRoutes()); }; diff --git a/packages/api/src/routes/org/projects.test.ts b/packages/api/src/routes/org/projects.test.ts new file mode 100644 index 00000000..9768de89 --- /dev/null +++ b/packages/api/src/routes/org/projects.test.ts @@ -0,0 +1,1747 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Hono } from "hono"; +import type { ApiEnv } from "../../types"; + +// `/v1/projects` end-to-end through the real app: the OSS routes mounted on +// the `eeRoutes` seam, the OSS role resolver wired as the RoleResolver, and +// `CAPS.rbac` on. Same harness shape as groups.test.ts — cloned, not shared — +// except that `project_access` is a REAL table here rather than the +// `findFirst: async () => null` stub the org suites use: these rows are the +// authorization data three enforcement points read. +// +// Admin callers arrive with an org API key; the non-admin cases use a session, +// since a non-admin's org key fails key authentication outright. + +const ORG = "org-1"; +const OTHER_ORG = "org-2"; +const OWNER = "user-owner"; +const ADMIN = "user-admin"; +const MEMBER = "user-member"; +const MEMBER2 = "user-member2"; +const OUTSIDER = "user-outsider"; +/** A real User row with NO membership in either org (Decision J's filter). */ +const STRANGER = "user-stranger"; +const ADMIN_KEY = "oc_org_admin-key"; +const PROJECT_KEY = "oc_project-key-of-owner"; + +vi.hoisted(() => { + process.env.NEXT_PUBLIC_EDITION = "oss"; + process.env.SECRET_ENCRYPTION_KEY = "test-secret"; + process.env.OAUTH_STATE_SECRET = "test-secret"; +}); + +interface MemberRow { + organizationId: string; + userId: string; + role: string; + status: string; + createdAt: Date; +} + +interface UserRow { + id: string; + externalAuthId: string; + email: string; + name: string | null; +} + +interface ProjectRow { + id: string; + organizationId: string; + name: string | null; + slug: string | null; + createdByUserId: string | null; + createdAt: Date; +} + +interface GroupRow { + id: string; + organizationId: string; + name: string; + source: string; +} + +interface GroupMemberRow { + groupId: string; + userId: string; +} + +interface AccessRow { + id: string; + projectId: string; + userId: string | null; + groupId: string | null; + role: string; + createdByUserId: string | null; + createdAt: Date; +} + +/** Every project-child table this suite exercises shares this shape. */ +interface ChildRow { + id: string; + projectId: string; +} + +interface KeyRow extends ChildRow { + key: string; +} + +interface AuditRow { + organizationId?: string; + projectId?: string; + userId: string; + action: string; + service: string; + source: string; + metadata: Record; +} + +const store = vi.hoisted(() => ({ + users: [] as UserRow[], + members: [] as MemberRow[], + projects: [] as ProjectRow[], + groups: [] as GroupRow[], + groupMembers: [] as GroupMemberRow[], + projectAccess: [] as AccessRow[], + agents: [] as ChildRow[], + apiKeys: [] as KeyRow[], + secrets: [] as ChildRow[], + appConnections: [] as ChildRow[], + appConfigs: [] as ChildRow[], + policyRules: [] as ChildRow[], + policyRulesV2: [] as ChildRow[], + vaultConnections: [] as ChildRow[], + budgets: [] as ChildRow[], + onboardingSurveys: [] as ChildRow[], + audits: [] as AuditRow[], + seq: 0, + txCount: 0, + /** Which user the session provider resolves to (null = no session). */ + sessionUserId: null as string | null, +})); + +/** Gateway flushes are spied, never fetched: the DELETE path must hand the + * keys it captured BEFORE the delete to invalidateGatewayCacheForKeys. */ +const flushes = vi.hoisted(() => ({ + keys: [] as string[][], + orgs: [] as string[], + accounts: [] as string[], +})); + +vi.mock("../../lib/gateway-invalidate", () => ({ + invalidateGatewayCache: () => {}, + invalidateGatewayCacheForKeys: (keys: string[]) => { + flushes.keys.push(keys); + }, + invalidateGatewayCacheForAccount: (projectId: string) => { + flushes.accounts.push(projectId); + }, + invalidateGatewayCacheForOrg: (organizationId: string) => { + flushes.orgs.push(organizationId); + }, +})); + +vi.mock("@onecli/db", () => { + // ── where shapes these routes actually build ──────────────────────────── + interface StringFilter { + not?: string | null; + in?: string[]; + } + interface BindingClause { + userId?: string; + group?: { members: { some: { userId: string } } }; + } + interface ProjectWhere { + id?: string | StringFilter; + organizationId?: string; + createdByUserId?: string; + organization?: { + members: { some: { userId: string; status?: { not?: string } } }; + }; + accessBindings?: { some: { OR: BindingClause[] } }; + OR?: ProjectWhere[]; + } + interface AccessWhere { + projectId?: string; + userId?: string | StringFilter | null; + groupId?: string | StringFilter | null; + role?: string; + user?: { organizationMemberships: { some: { organizationId: string } } }; + group?: { organizationId: string }; + OR?: BindingClause[]; + } + + const matchesString = ( + value: string | null, + filter: string | StringFilter | null | undefined, + ): boolean => { + if (filter === undefined) return true; + if (filter === null) return value === null; + if (typeof filter === "string") return value === filter; + if (filter.in !== undefined) + return value !== null && filter.in.includes(value); + if ("not" in filter) { + if (filter.not === null) return value !== null; + return value !== filter.not; + } + return true; + }; + + /** Does `projectId` carry a binding satisfying any of the OR clauses? */ + const matchesBindingClause = (projectId: string, clauses: BindingClause[]) => + clauses.some((clause) => { + if (clause.userId !== undefined) { + return store.projectAccess.some( + (pa) => pa.projectId === projectId && pa.userId === clause.userId, + ); + } + const userId = clause.group?.members.some.userId; + if (userId === undefined) return false; + return store.projectAccess.some( + (pa) => + pa.projectId === projectId && + pa.groupId !== null && + store.groupMembers.some( + (gm) => gm.groupId === pa.groupId && gm.userId === userId, + ), + ); + }); + + const matchesProject = (row: ProjectRow, where: ProjectWhere): boolean => { + if (!matchesString(row.id, where.id)) return false; + if ( + where.organizationId !== undefined && + row.organizationId !== where.organizationId + ) + return false; + if ( + where.createdByUserId !== undefined && + row.createdByUserId !== where.createdByUserId + ) + return false; + if (where.organization) { + const { userId, status } = where.organization.members.some; + const membership = store.members.find( + (m) => m.organizationId === row.organizationId && m.userId === userId, + ); + if (!membership) return false; + if (status?.not !== undefined && membership.status === status.not) + return false; + } + if ( + where.accessBindings && + !matchesBindingClause(row.id, where.accessBindings.some.OR) + ) + return false; + if (where.OR && !where.OR.some((sub) => matchesProject(row, sub))) + return false; + return true; + }; + + const matchesAccess = (row: AccessRow, where: AccessWhere): boolean => { + if (where.projectId !== undefined && row.projectId !== where.projectId) + return false; + if (!matchesString(row.userId, where.userId)) return false; + if (!matchesString(row.groupId, where.groupId)) return false; + if (where.role !== undefined && row.role !== where.role) return false; + if (where.user) { + const organizationId = + where.user.organizationMemberships.some.organizationId; + const isMember = store.members.some( + (m) => m.userId === row.userId && m.organizationId === organizationId, + ); + if (!isMember) return false; + } + if (where.group) { + const group = store.groups.find((g) => g.id === row.groupId); + if (!group || group.organizationId !== where.group.organizationId) + return false; + } + if (where.OR && !matchesBindingClause(row.projectId, where.OR)) + return false; + return true; + }; + + interface AccessSelect { + id?: boolean; + userId?: boolean; + groupId?: boolean; + role?: boolean; + createdAt?: boolean; + user?: { select: { email?: boolean; name?: boolean } }; + group?: { + select: { + name?: boolean; + _count?: { select: { members?: boolean } }; + members?: { select: { userId?: boolean } }; + }; + }; + } + + const pickAccess = (row: AccessRow, select?: AccessSelect) => { + if (!select) return { ...row }; + const picked: Record = {}; + for (const key of [ + "id", + "userId", + "groupId", + "role", + "createdAt", + ] as const) { + if (select[key]) picked[key] = row[key]; + } + if (select.user) { + const user = store.users.find((u) => u.id === row.userId); + picked.user = user + ? { email: user.email, name: user.name } + : { email: "", name: null }; + } + if (select.group) { + const group = store.groups.find((g) => g.id === row.groupId); + const members = store.groupMembers.filter( + (gm) => gm.groupId === row.groupId, + ); + const value: Record = {}; + if (select.group.select.name) value.name = group?.name ?? ""; + if (select.group.select._count) + value._count = { members: members.length }; + if (select.group.select.members) + value.members = members.map((m) => ({ userId: m.userId })); + picked.group = group ? value : null; + } + return picked; + }; + + /** Every `projects`-child table shares count/deleteMany, keyed by projectId. */ + const childDelegate = ( + read: () => T[], + write: (rows: T[]) => void, + ) => ({ + count: async ({ where }: { where: { projectId: string } }) => + read().filter((row) => row.projectId === where.projectId).length, + deleteMany: async ({ where }: { where: { projectId: string } }) => { + const before = read().length; + write(read().filter((row) => row.projectId !== where.projectId)); + return { count: before - read().length }; + }, + }); + + const delegates = { + user: { + findUnique: async ({ + where, + select, + }: { + where: { id?: string; externalAuthId?: string }; + select?: Record; + }) => { + const user = store.users.find( + (u) => + (where.id !== undefined && u.id === where.id) || + (where.externalAuthId !== undefined && + u.externalAuthId === where.externalAuthId), + ); + if (!user) return null; + if (select?.organizationMemberships) { + return { + organizationMemberships: store.members + .filter((m) => m.userId === user.id) + .map((m) => ({ organizationId: m.organizationId })), + }; + } + return user; + }, + }, + organizationMember: { + findUnique: async ({ + where, + }: { + where: { + organizationId_userId: { organizationId: string; userId: string }; + }; + }) => { + const { organizationId, userId } = where.organizationId_userId; + return ( + store.members.find( + (m) => m.organizationId === organizationId && m.userId === userId, + ) ?? null + ); + }, + findFirst: async ({ + where, + }: { + where: { + organizationId?: string; + userId?: string; + status?: string | { not?: string }; + }; + }) => + store.members + .slice() + .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + .find( + (row) => + (where.organizationId === undefined || + row.organizationId === where.organizationId) && + (where.userId === undefined || row.userId === where.userId) && + (where.status === undefined || + (typeof where.status === "string" + ? row.status === where.status + : where.status.not === undefined || + row.status !== where.status.not)), + ) ?? null, + findMany: async ({ + where, + }: { + where: { + organizationId?: string; + userId?: { in: string[] }; + status?: { not?: string }; + }; + }) => + store.members + .filter( + (row) => + (where.organizationId === undefined || + row.organizationId === where.organizationId) && + (where.userId === undefined || + where.userId.in.includes(row.userId)) && + (where.status?.not === undefined || + row.status !== where.status.not), + ) + .map((row) => ({ userId: row.userId })), + }, + project: { + findFirst: async ({ + where, + select, + }: { + where: ProjectWhere; + select?: Record; + }) => { + const row = store.projects + .slice() + .sort( + (a, b) => + a.createdAt.getTime() - b.createdAt.getTime() || + a.id.localeCompare(b.id), + ) + .find((p) => matchesProject(p, where)); + if (!row) return null; + if (!select) return { ...row }; + const picked: Record = {}; + for (const key of Object.keys(select)) { + if (select[key]) picked[key] = row[key as keyof ProjectRow]; + } + return picked; + }, + findUnique: async ({ + where, + select, + }: { + where: { id: string }; + select?: Record; + }) => { + const row = store.projects.find((p) => p.id === where.id); + if (!row) return null; + if (!select) return { ...row }; + const picked: Record = {}; + for (const key of Object.keys(select)) { + if (select[key]) picked[key] = row[key as keyof ProjectRow]; + } + return picked; + }, + count: async ({ where }: { where: ProjectWhere }) => + store.projects.filter((p) => matchesProject(p, where)).length, + updateMany: async ({ + where, + data, + }: { + where: ProjectWhere; + data: { name?: string }; + }) => { + const rows = store.projects.filter((p) => matchesProject(p, where)); + for (const row of rows) { + if (data.name !== undefined) row.name = data.name; + } + return { count: rows.length }; + }, + deleteMany: async ({ where }: { where: ProjectWhere }) => { + const rows = store.projects.filter((p) => matchesProject(p, where)); + const ids = new Set(rows.map((r) => r.id)); + store.projects = store.projects.filter((p) => !ids.has(p.id)); + // The DB CASCADEs that ride the project row: project_access and + // policy_rules_v2. + store.projectAccess = store.projectAccess.filter( + (pa) => !ids.has(pa.projectId), + ); + store.policyRulesV2 = store.policyRulesV2.filter( + (r) => !ids.has(r.projectId), + ); + return { count: rows.length }; + }, + }, + projectAccess: { + findFirst: async ({ + where, + select, + }: { + where: AccessWhere; + select?: AccessSelect; + }) => { + const row = store.projectAccess.find((pa) => matchesAccess(pa, where)); + return row ? pickAccess(row, select) : null; + }, + findMany: async ({ + where, + select, + take, + }: { + where: AccessWhere; + select?: AccessSelect; + take?: number; + }) => { + const rows = store.projectAccess + .filter((pa) => matchesAccess(pa, where)) + .sort( + (a, b) => + a.createdAt.getTime() - b.createdAt.getTime() || + a.id.localeCompare(b.id), + ); + const limited = take === undefined ? rows : rows.slice(0, take); + return limited.map((row) => pickAccess(row, select)); + }, + count: async ({ where }: { where: AccessWhere }) => + store.projectAccess.filter((pa) => matchesAccess(pa, where)).length, + updateMany: async ({ + where, + data, + }: { + where: AccessWhere; + data: { role: string }; + }) => { + const rows = store.projectAccess.filter((pa) => + matchesAccess(pa, where), + ); + for (const row of rows) row.role = data.role; + return { count: rows.length }; + }, + deleteMany: async ({ where }: { where: AccessWhere }) => { + const rows = store.projectAccess.filter((pa) => + matchesAccess(pa, where), + ); + const ids = new Set(rows.map((r) => r.id)); + store.projectAccess = store.projectAccess.filter( + (pa) => !ids.has(pa.id), + ); + return { count: ids.size }; + }, + createMany: async ({ + data, + }: { + data: { + projectId: string; + userId?: string; + groupId?: string; + role: string; + createdByUserId: string | null; + }[]; + skipDuplicates?: boolean; + }) => { + let count = 0; + for (const row of data) { + const dupe = store.projectAccess.some( + (pa) => + pa.projectId === row.projectId && + ((row.userId !== undefined && pa.userId === row.userId) || + (row.groupId !== undefined && pa.groupId === row.groupId)), + ); + if (dupe) continue; // skipDuplicates + store.projectAccess.push({ + id: `pa-${++store.seq}`, + projectId: row.projectId, + // Stored verbatim so the test can assert the exactly-one-of DB + // CHECK the mock itself cannot enforce. + userId: row.userId ?? null, + groupId: row.groupId ?? null, + role: row.role, + createdByUserId: row.createdByUserId, + createdAt: new Date(Date.UTC(2026, 1, 1, 0, store.seq)), + }); + count++; + } + return { count }; + }, + }, + group: { + findMany: async ({ + where, + }: { + where: { organizationId: string; id: { in: string[] } }; + }) => + store.groups + .filter( + (g) => + g.organizationId === where.organizationId && + where.id.in.includes(g.id), + ) + .map((g) => ({ id: g.id })), + }, + apiKey: { + findUnique: async ({ where }: { where: { key?: string } }) => { + if (where.key === ADMIN_KEY) + return { + userId: ADMIN, + organizationId: ORG, + scope: "organization", + }; + // A PROJECT-scoped key owned by the org's OWNER: it authenticates + // fine, which is exactly why the router needs its own scope guard. + if (where.key === PROJECT_KEY) + return { userId: OWNER, projectId: "proj-2" }; + return null; + }, + findFirst: async () => null, + findMany: async ({ + where, + }: { + where: { projectId?: string; project?: { organizationId: string } }; + }) => + store.apiKeys + .filter( + (row) => + where.projectId === undefined || + row.projectId === where.projectId, + ) + .map((row) => ({ key: row.key })), + ...childDelegate( + () => store.apiKeys, + (rows) => { + store.apiKeys = rows; + }, + ), + }, + agent: childDelegate( + () => store.agents, + (rows) => { + store.agents = rows; + }, + ), + secret: childDelegate( + () => store.secrets, + (rows) => { + store.secrets = rows; + }, + ), + appConnection: childDelegate( + () => store.appConnections, + (rows) => { + store.appConnections = rows; + }, + ), + appConfig: childDelegate( + () => store.appConfigs, + (rows) => { + store.appConfigs = rows; + }, + ), + policyRule: childDelegate( + () => store.policyRules, + (rows) => { + store.policyRules = rows; + }, + ), + policyRuleV2: childDelegate( + () => store.policyRulesV2, + (rows) => { + store.policyRulesV2 = rows; + }, + ), + vaultConnection: childDelegate( + () => store.vaultConnections, + (rows) => { + store.vaultConnections = rows; + }, + ), + budget: childDelegate( + () => store.budgets, + (rows) => { + store.budgets = rows; + }, + ), + onboardingSurvey: childDelegate( + () => store.onboardingSurveys, + (rows) => { + store.onboardingSurveys = rows; + }, + ), + auditLog: { + create: async ({ data }: { data: AuditRow }) => { + store.audits.push(data); + return data; + }, + }, + }; + + return { + Prisma: { JsonNull: null }, + db: { + ...delegates, + // Both forms: the access replace-set uses the array form, the delete + // cascade the interactive (callback) form. + $transaction: async (arg: unknown) => { + store.txCount++; + if (typeof arg === "function") { + return (arg as (tx: typeof delegates) => Promise)(delegates); + } + return Promise.all(arg as Promise[]); + }, + }, + }; +}); + +import { createApiApp } from "../../app"; +import { registerOssOrgRoutes } from "./index"; +import { ossRoleResolver } from "../../services/org-role-resolver"; + +const sessionProvider = { + getSession: async () => { + const user = store.users.find((u) => u.id === store.sessionUserId); + return user ? { id: user.externalAuthId, email: user.email } : null; + }, +}; + +const app: Hono = createApiApp(sessionProvider, { + eeRoutes: registerOssOrgRoutes, + roleResolver: ossRoleResolver, +}); + +const at = (minutes: number) => new Date(Date.UTC(2026, 0, 1, 0, minutes)); + +const member = ( + userId: string, + role: string, + createdAt: Date, + organizationId = ORG, +): MemberRow => ({ + organizationId, + userId, + role, + status: "active", + createdAt, +}); + +const access = ( + id: string, + projectId: string, + principal: { userId?: string; groupId?: string }, + role: string, + createdAt: Date, +): AccessRow => ({ + id, + projectId, + userId: principal.userId ?? null, + groupId: principal.groupId ?? null, + role, + createdByUserId: null, + createdAt, +}); + +beforeEach(() => { + store.users = [ + { + id: OWNER, + externalAuthId: "ext-owner", + email: "owner@example.com", + name: "Olive Owner", + }, + { + id: ADMIN, + externalAuthId: "ext-admin", + email: "admin@example.com", + name: "Adam Admin", + }, + { + id: MEMBER, + externalAuthId: "ext-member", + email: "member@example.com", + name: null, + }, + { + id: MEMBER2, + externalAuthId: "ext-member2", + email: "member2@example.com", + name: "Mia Member", + }, + { + id: OUTSIDER, + externalAuthId: "ext-outsider", + email: "outsider@other.test", + name: "Odette Outsider", + }, + { + id: STRANGER, + externalAuthId: "ext-stranger", + email: "stranger@nowhere.test", + name: "Sam Stranger", + }, + ]; + store.members = [ + member(OWNER, "owner", at(0)), + member(ADMIN, "admin", at(1)), + member(MEMBER, "member", at(2)), + member(MEMBER2, "member", at(3)), + member(OUTSIDER, "admin", at(4), OTHER_ORG), + ]; + store.projects = [ + { + id: "proj-1", + organizationId: ORG, + name: "Alpha", + slug: "alpha", + createdByUserId: MEMBER, + createdAt: at(0), + }, + { + id: "proj-2", + organizationId: ORG, + name: "Beta", + slug: "beta", + createdByUserId: OWNER, + createdAt: at(1), + }, + { + id: "proj-3", + organizationId: ORG, + name: "Gamma", + slug: "gamma", + createdByUserId: ADMIN, + createdAt: at(2), + }, + // No bindings at all — the legacy zero-binding shape (L5). + { + id: "proj-4", + organizationId: ORG, + name: "Delta", + slug: "delta", + createdByUserId: OWNER, + createdAt: at(3), + }, + { + id: "proj-x", + organizationId: OTHER_ORG, + name: "Foreign", + slug: "foreign", + createdByUserId: OUTSIDER, + createdAt: at(4), + }, + ]; + store.groups = [ + { id: "g-a", organizationId: ORG, name: "Engineering", source: "manual" }, + { id: "g-scim", organizationId: ORG, name: "Provisioned", source: "scim" }, + { id: "g-x", organizationId: OTHER_ORG, name: "Foreign", source: "manual" }, + ]; + store.groupMembers = [ + { groupId: "g-a", userId: MEMBER2 }, + { groupId: "g-x", userId: OUTSIDER }, + ]; + store.projectAccess = [ + access("pa-1", "proj-1", { userId: MEMBER }, "owner", at(10)), + // A GROUP row carrying role "owner" on purpose: group bindings must never + // confer management, whatever the column says. + access("pa-2", "proj-1", { groupId: "g-a" }, "owner", at(11)), + access("pa-3", "proj-1", { userId: ADMIN }, "member", at(12)), + access("pa-4", "proj-2", { userId: OWNER }, "owner", at(13)), + access("pa-5", "proj-2", { userId: MEMBER2 }, "member", at(14)), + access("pa-6", "proj-3", { userId: ADMIN }, "owner", at(15)), + // Inert rows, filtered out of GET /access (Decision J + the org fence). + access("pa-7", "proj-3", { userId: STRANGER }, "member", at(16)), + access("pa-8", "proj-3", { groupId: "g-x" }, "member", at(17)), + access("pa-9", "proj-x", { userId: OUTSIDER }, "owner", at(18)), + ]; + store.agents = [ + { id: "ag-1", projectId: "proj-1" }, + { id: "ag-2", projectId: "proj-1" }, + { id: "ag-3", projectId: "proj-2" }, + ]; + store.apiKeys = [ + { id: "k-1", projectId: "proj-1", key: "oc_key-1" }, + { id: "k-2", projectId: "proj-1", key: "oc_key-2" }, + { id: "k-3", projectId: "proj-2", key: "oc_key-3" }, + ]; + store.secrets = [ + { id: "s-1", projectId: "proj-1" }, + { id: "s-2", projectId: "proj-2" }, + ]; + store.appConnections = [{ id: "ac-1", projectId: "proj-1" }]; + store.appConfigs = [{ id: "cfg-1", projectId: "proj-1" }]; + store.policyRules = [{ id: "pr-1", projectId: "proj-1" }]; + store.policyRulesV2 = [{ id: "pv-1", projectId: "proj-1" }]; + store.vaultConnections = [{ id: "vc-1", projectId: "proj-1" }]; + store.budgets = [{ id: "b-1", projectId: "proj-1" }]; + store.onboardingSurveys = [{ id: "os-1", projectId: "proj-1" }]; + store.audits = []; + store.seq = 100; + store.txCount = 0; + store.sessionUserId = null; + flushes.keys = []; + flushes.orgs = []; + flushes.accounts = []; +}); + +const asAdmin = { headers: { Authorization: `Bearer ${ADMIN_KEY}` } }; +const asProjectKey = { headers: { Authorization: `Bearer ${PROJECT_KEY}` } }; + +const projectRow = (id: string) => store.projects.find((p) => p.id === id); +const bindings = (projectId: string) => + store.projectAccess.filter((pa) => pa.projectId === projectId); +const userBinding = (projectId: string, userId: string) => + bindings(projectId).find((pa) => pa.userId === userId); + +interface ProjectBody { + id: string; + name: string | null; + slug: string | null; + createdAt: string; +} + +interface AccessBody { + users: { + id: string; + userId: string; + name: string | null; + email: string; + role: string; + isOwner: boolean; + createdAt: string; + }[]; + groups: { + id: string; + groupId: string; + name: string; + memberCount: number; + createdAt: string; + }[]; +} + +const get = (id: string, init: RequestInit = asAdmin) => + app.request(`/v1/projects/${id}`, init); + +const patch = (id: string, body: unknown, init: RequestInit = asAdmin) => + app.request(`/v1/projects/${id}`, { + ...init, + method: "PATCH", + body: JSON.stringify(body), + }); + +const remove = (id: string, init: RequestInit = asAdmin) => + app.request(`/v1/projects/${id}`, { ...init, method: "DELETE" }); + +const getAccess = (id: string, init: RequestInit = asAdmin) => + app.request(`/v1/projects/${id}/access`, init); + +const putAccess = (id: string, body: unknown, init: RequestInit = asAdmin) => + app.request(`/v1/projects/${id}/access`, { + ...init, + method: "PUT", + body: JSON.stringify(body), + }); + +describe("guard stack", () => { + it("401s an unauthenticated caller on every route", async () => { + expect((await get("proj-1", {})).status).toBe(401); + expect((await patch("proj-1", { name: "X" }, {})).status).toBe(401); + expect((await remove("proj-1", {})).status).toBe(401); + expect((await getAccess("proj-1", {})).status).toBe(401); + expect( + (await putAccess("proj-1", { users: [], groupIds: [] }, {})).status, + ).toBe(401); + expect(store.audits).toHaveLength(0); + }); + + it("403s a project-scoped key on every route, even when its user is an org owner", async () => { + expect((await get("proj-2", asProjectKey)).status).toBe(403); + expect((await patch("proj-2", { name: "X" }, asProjectKey)).status).toBe( + 403, + ); + expect((await remove("proj-2", asProjectKey)).status).toBe(403); + expect((await getAccess("proj-2", asProjectKey)).status).toBe(403); + expect( + ( + await putAccess( + "proj-2", + { users: [{ userId: OWNER, role: "owner" }], groupIds: [] }, + asProjectKey, + ) + ).status, + ).toBe(403); + expect(projectRow("proj-2")?.name).toBe("Beta"); + expect(store.audits).toHaveLength(0); + }); + + it("200s a NON-ADMIN member holding an owner binding (the point of this stack)", async () => { + store.sessionUserId = MEMBER; + const res = await patch("proj-1", { name: "Renamed" }, {}); + expect(res.status).toBe(200); + expect(projectRow("proj-1")?.name).toBe("Renamed"); + }); + + it("403s an active member whose binding is a plain use grant", async () => { + store.sessionUserId = MEMBER2; // `member` binding on proj-2 + const res = await patch("proj-2", { name: "Nope" }, {}); + expect(res.status).toBe(403); + expect(projectRow("proj-2")?.name).toBe("Beta"); + expect(store.audits).toHaveLength(0); + }); + + it("403s a member whose only binding is a GROUP row, even at role owner", async () => { + store.sessionUserId = MEMBER2; // in g-a, which is bound to proj-1 as "owner" + const res = await patch("proj-1", { name: "Nope" }, {}); + expect(res.status).toBe(403); + expect(projectRow("proj-1")?.name).toBe("Alpha"); + }); + + it("rejects a suspended admin's org key (suspended reads as no role)", async () => { + const row = store.members.find((m) => m.userId === ADMIN); + if (row) row.status = "suspended"; + // The key path fails first, so this is a 401 rather than the 403 a + // suspended session would get — either way the stale binding on proj-3 + // never rescues them. + expect((await patch("proj-3", { name: "X" })).status).toBe(401); + expect(projectRow("proj-3")?.name).toBe("Gamma"); + }); + + it("rejects a suspended owner-binding holder's session", async () => { + const row = store.members.find((m) => m.userId === MEMBER); + if (row) row.status = "suspended"; + store.sessionUserId = MEMBER; + expect((await patch("proj-1", { name: "X" }, {})).status).toBe(401); + expect(projectRow("proj-1")?.name).toBe("Alpha"); + }); +}); + +describe("GET /v1/projects/:projectId", () => { + it("returns the project row with createdAt as an ISO string", async () => { + const res = await get("proj-1"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + id: "proj-1", + name: "Alpha", + slug: "alpha", + createdAt: at(0).toISOString(), + }); + }); + + it("404s a project of another organization and an unknown id", async () => { + expect((await get("proj-x")).status).toBe(404); + expect((await get("proj-nope")).status).toBe(404); + }); + + it("200s a plain member holding a use-only binding", async () => { + store.sessionUserId = MEMBER2; // group binding on proj-1 + const res = await get("proj-1", {}); + expect(res.status).toBe(200); + expect(((await res.json()) as ProjectBody).id).toBe("proj-1"); + }); + + it("200s an org admin with no binding at all", async () => { + const res = await get("proj-4"); // zero bindings + expect(res.status).toBe(200); + }); + + it("403s an active member with no binding on the project", async () => { + store.sessionUserId = MEMBER2; + expect((await get("proj-3", {})).status).toBe(403); + }); + + it("never audits a read", async () => { + await get("proj-1"); + expect(store.audits).toHaveLength(0); + }); +}); + +describe("PATCH /v1/projects/:projectId", () => { + it("renames, returns the row, and audits with organizationId AND projectId", async () => { + const res = await patch("proj-1", { name: "Alpha Prime" }); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ + id: "proj-1", + name: "Alpha Prime", + slug: "alpha", + }); + expect(projectRow("proj-1")?.name).toBe("Alpha Prime"); + expect(store.audits).toHaveLength(1); + expect(store.audits[0]).toMatchObject({ + organizationId: ORG, + projectId: "proj-1", + userId: ADMIN, + action: "update", + service: "project", + source: "api", + metadata: { projectId: "proj-1", change: "name", name: "Alpha Prime" }, + }); + }); + + it("trims the name before storing", async () => { + const res = await patch("proj-1", { name: " Padded " }); + expect(res.status).toBe(200); + expect(projectRow("proj-1")?.name).toBe("Padded"); + }); + + it("422s an empty / whitespace-only / overlong / missing name", async () => { + for (const body of [ + { name: "" }, + { name: " " }, + { name: "x".repeat(101) }, + {}, + ]) { + expect((await patch("proj-1", body)).status).toBe(422); + } + expect(projectRow("proj-1")?.name).toBe("Alpha"); + expect(store.audits).toHaveLength(0); + }); + + it("422s an unparseable body rather than 500ing", async () => { + const res = await app.request("/v1/projects/proj-1", { + ...asAdmin, + method: "PATCH", + }); + expect(res.status).toBe(422); + }); + + it("permits a rename-to-self as a 200 no-op", async () => { + const res = await patch("proj-1", { name: "Alpha" }); + expect(res.status).toBe(200); + expect(projectRow("proj-1")?.name).toBe("Alpha"); + }); + + it("lets two projects in the same org share a name (the 'Default' reality)", async () => { + const res = await patch("proj-2", { name: "Alpha" }); + expect(res.status).toBe(200); + expect(projectRow("proj-1")?.name).toBe("Alpha"); + expect(projectRow("proj-2")?.name).toBe("Alpha"); + }); + + it("never writes slug", async () => { + await patch("proj-1", { name: "Alpha Prime", slug: "hijacked" }); + expect(projectRow("proj-1")?.slug).toBe("alpha"); + }); + + it("404s a project of another organization and audits nothing", async () => { + const res = await patch("proj-x", { name: "Captured" }); + expect(res.status).toBe(404); + expect(projectRow("proj-x")?.name).toBe("Foreign"); + expect(store.audits).toHaveLength(0); + }); + + it("403s a non-manager and writes/audits nothing", async () => { + store.sessionUserId = MEMBER2; + const res = await patch("proj-2", { name: "Nope" }, {}); + expect(res.status).toBe(403); + expect(projectRow("proj-2")?.name).toBe("Beta"); + expect(store.audits).toHaveLength(0); + }); +}); + +describe("GET /v1/projects/:projectId/access", () => { + it("returns users and groups in the client's exact shape, createdAt asc", async () => { + const res = await getAccess("proj-1"); + expect(res.status).toBe(200); + const body = (await res.json()) as AccessBody; + expect(body.users).toEqual([ + { + id: "pa-1", + userId: MEMBER, + name: null, + email: "member@example.com", + role: "owner", + isOwner: true, + createdAt: at(10).toISOString(), + }, + { + id: "pa-3", + userId: ADMIN, + name: "Adam Admin", + email: "admin@example.com", + role: "member", + isOwner: false, + createdAt: at(12).toISOString(), + }, + ]); + expect(body.groups).toEqual([ + { + id: "pa-2", + groupId: "g-a", + name: "Engineering", + memberCount: 1, + createdAt: at(11).toISOString(), + }, + ]); + }); + + it("normalizes a garbage role string to member instead of casting it", async () => { + const row = store.projectAccess.find((pa) => pa.id === "pa-3"); + if (row) row.role = "superuser"; + const body = (await (await getAccess("proj-1")).json()) as AccessBody; + expect(body.users.find((u) => u.userId === ADMIN)?.role).toBe("member"); + }); + + it("keeps isOwner as creator provenance, independent of the management role", async () => { + // Creator demoted, a non-creator promoted: the badge follows creation. + const creatorRow = store.projectAccess.find((pa) => pa.id === "pa-1"); + if (creatorRow) creatorRow.role = "member"; + const otherRow = store.projectAccess.find((pa) => pa.id === "pa-3"); + if (otherRow) otherRow.role = "owner"; + + const body = (await (await getAccess("proj-1")).json()) as AccessBody; + expect(body.users.find((u) => u.userId === MEMBER)).toMatchObject({ + role: "member", + isOwner: true, + }); + expect(body.users.find((u) => u.userId === ADMIN)).toMatchObject({ + role: "owner", + isOwner: false, + }); + }); + + it("excludes a user who is not a member of the org, and a group of another org", async () => { + const body = (await (await getAccess("proj-3")).json()) as AccessBody; + expect(body.users.map((u) => u.userId)).toEqual([ADMIN]); + expect(body.groups).toEqual([]); + }); + + it("includes a SUSPENDED member's row (suspension is an auth-time gate)", async () => { + const row = store.members.find((m) => m.userId === ADMIN); + if (row) row.status = "suspended"; + store.sessionUserId = MEMBER; // the suspended admin can no longer call in + const body = (await (await getAccess("proj-1", {})).json()) as AccessBody; + expect(body.users.map((u) => u.userId)).toEqual([MEMBER, ADMIN]); + }); + + it("returns empty arrays for a project with no bindings, not a 404", async () => { + const res = await getAccess("proj-4"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ users: [], groups: [] }); + }); + + it("404s a project of another organization", async () => { + expect((await getAccess("proj-x")).status).toBe(404); + }); +}); + +describe("PUT /v1/projects/:projectId/access (replace-set)", () => { + it("applies the exact set and returns the aggregated delta", async () => { + // proj-1 currently: users {MEMBER owner, ADMIN member}, groups {g-a}. + const res = await putAccess("proj-1", { + users: [ + { userId: MEMBER, role: "owner" }, + { userId: MEMBER2, role: "member" }, + ], + groupIds: ["g-scim"], + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ added: 2, removed: 2, roleChanged: 0 }); + + expect( + bindings("proj-1") + .map((pa) => pa.userId ?? `group:${pa.groupId}`) + .sort(), + ).toEqual([MEMBER, MEMBER2, "group:g-scim"].sort()); + expect(store.txCount).toBe(1); + }); + + it("changes a role in place without recreating the row", async () => { + const before = userBinding("proj-1", MEMBER)?.id; + const res = await putAccess("proj-1", { + users: [ + { userId: MEMBER, role: "member" }, + { userId: ADMIN, role: "owner" }, + ], + groupIds: ["g-a"], + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ added: 0, removed: 0, roleChanged: 2 }); + // The row id (and therefore its createdAt provenance) survives. + expect(userBinding("proj-1", MEMBER)?.id).toBe(before); + expect(userBinding("proj-1", MEMBER)?.role).toBe("member"); + expect(userBinding("proj-1", ADMIN)?.role).toBe("owner"); + }); + + it("returns {0,0,0} for a no-op set WITHOUT opening a transaction", async () => { + const res = await putAccess("proj-1", { + users: [ + { userId: MEMBER, role: "owner" }, + { userId: ADMIN, role: "member" }, + ], + groupIds: ["g-a"], + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ added: 0, removed: 0, roleChanged: 0 }); + expect(store.txCount).toBe(0); + expect(bindings("proj-1")).toHaveLength(3); + }); + + it("deduplicates repeated groupIds silently", async () => { + const res = await putAccess("proj-1", { + users: [ + { userId: MEMBER, role: "owner" }, + { userId: ADMIN, role: "member" }, + ], + groupIds: ["g-a", "g-a"], + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ added: 0, removed: 0, roleChanged: 0 }); + }); + + it("422s a duplicate userId (ambiguous role), not 'last wins'", async () => { + const res = await putAccess("proj-1", { + users: [ + { userId: MEMBER, role: "owner" }, + { userId: MEMBER, role: "member" }, + ], + groupIds: [], + }); + expect(res.status).toBe(422); + expect(userBinding("proj-1", MEMBER)?.role).toBe("owner"); + }); + + it("422s a body missing either key — never a half-wipe", async () => { + expect( + ( + await putAccess("proj-1", { + users: [{ userId: MEMBER, role: "owner" }], + }) + ).status, + ).toBe(422); + expect((await putAccess("proj-1", { groupIds: [] })).status).toBe(422); + expect(bindings("proj-1")).toHaveLength(3); + expect(store.audits).toHaveLength(0); + }); + + it("422s arrays beyond the caps", async () => { + const users = Array.from({ length: 1001 }, (_, i) => ({ + userId: `u-${i}`, + role: "member" as const, + })); + expect((await putAccess("proj-1", { users, groupIds: [] })).status).toBe( + 422, + ); + const groupIds = Array.from({ length: 201 }, (_, i) => `g-${i}`); + expect( + ( + await putAccess("proj-1", { + users: [{ userId: MEMBER, role: "owner" }], + groupIds, + }) + ).status, + ).toBe(422); + }); + + it("400s when ANY userId is not a member of this org — the security core", async () => { + const res = await putAccess("proj-1", { + users: [ + { userId: MEMBER, role: "owner" }, + { userId: OUTSIDER, role: "member" }, + ], + groupIds: ["g-a"], + }); + expect(res.status).toBe(400); + expect(bindings("proj-1")).toHaveLength(3); + expect(store.txCount).toBe(0); + expect(store.audits).toHaveLength(0); + }); + + it("400s when a groupId belongs to another organization", async () => { + const res = await putAccess("proj-1", { + users: [{ userId: MEMBER, role: "owner" }], + groupIds: ["g-x"], + }); + expect(res.status).toBe(400); + expect(bindings("proj-1")).toHaveLength(3); + expect(store.audits).toHaveLength(0); + }); + + it("accepts a scim group as a grantee (a project grant is OneCLI-owned)", async () => { + const res = await putAccess("proj-1", { + users: [ + { userId: MEMBER, role: "owner" }, + { userId: ADMIN, role: "member" }, + ], + groupIds: ["g-a", "g-scim"], + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ added: 1, removed: 0, roleChanged: 0 }); + }); + + it("allows granting a suspended member (auth-time gate)", async () => { + const row = store.members.find((m) => m.userId === MEMBER2); + if (row) row.status = "suspended"; + const res = await putAccess("proj-1", { + users: [ + { userId: MEMBER, role: "owner" }, + { userId: ADMIN, role: "member" }, + { userId: MEMBER2, role: "member" }, + ], + groupIds: ["g-a"], + }); + expect(res.status).toBe(200); + expect(userBinding("proj-1", MEMBER2)).toBeTruthy(); + }); + + it("400s when the resulting set has no owner (demoted, or cleared)", async () => { + const demoted = await putAccess("proj-1", { + users: [ + { userId: MEMBER, role: "member" }, + { userId: ADMIN, role: "member" }, + ], + groupIds: ["g-a"], + }); + expect(demoted.status).toBe(400); + + const cleared = await putAccess("proj-1", { users: [], groupIds: [] }); + expect(cleared.status).toBe(400); + + expect(bindings("proj-1")).toHaveLength(3); + expect(userBinding("proj-1", MEMBER)?.role).toBe("owner"); + expect(store.audits).toHaveLength(0); + }); + + it("400s a NON-ADMIN actor removing or demoting their own binding", async () => { + store.sessionUserId = MEMBER; + const removed = await putAccess( + "proj-1", + { users: [{ userId: ADMIN, role: "owner" }], groupIds: ["g-a"] }, + {}, + ); + expect(removed.status).toBe(400); + + const demoted = await putAccess( + "proj-1", + { + users: [ + { userId: MEMBER, role: "member" }, + { userId: ADMIN, role: "owner" }, + ], + groupIds: ["g-a"], + }, + {}, + ); + expect(demoted.status).toBe(400); + + expect(userBinding("proj-1", MEMBER)?.role).toBe("owner"); + expect(store.audits).toHaveLength(0); + }); + + it("lets an ORG ADMIN remove their own binding (the hand-off exemption)", async () => { + const res = await putAccess("proj-1", { + users: [{ userId: MEMBER, role: "owner" }], + groupIds: ["g-a"], + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ added: 0, removed: 1, roleChanged: 0 }); + expect(userBinding("proj-1", ADMIN)).toBeUndefined(); + }); + + it("400s an ORG ADMIN whose own removal would leave them no project", async () => { + // Strip every path ADMIN has outside proj-1: the binding under the knife + // becomes their ONLY route to any project, so removing it would 401 them + // out of the dashboard — including out of the endpoint that re-grants. + const gamma = store.projects.find((p) => p.id === "proj-3"); + if (gamma) gamma.createdByUserId = OWNER; + store.projectAccess = store.projectAccess.filter((pa) => pa.id !== "pa-6"); + + const res = await putAccess("proj-1", { + users: [{ userId: MEMBER, role: "owner" }], + groupIds: ["g-a"], + }); + expect(res.status).toBe(400); + expect(JSON.stringify(await res.json())).toContain( + "leave you with no project", + ); + expect(userBinding("proj-1", ADMIN)).toBeTruthy(); + expect(store.txCount).toBe(0); + expect(store.audits).toHaveLength(0); + }); + + it("lets an ORG ADMIN drop their own binding on a project they CREATED", async () => { + // proj-3 is ADMIN's own project, so the created-by arm survives the write + // even with no binding left anywhere (their proj-1 row is removed here). + store.projectAccess = store.projectAccess.filter((pa) => pa.id !== "pa-3"); + + const res = await putAccess("proj-3", { + users: [{ userId: MEMBER, role: "owner" }], + groupIds: [], + }); + expect(res.status).toBe(200); + expect(userBinding("proj-3", ADMIN)).toBeUndefined(); + }); + + it("writes exactly one of userId/groupId per created row (the DB CHECK)", async () => { + const res = await putAccess("proj-1", { + users: [ + { userId: MEMBER, role: "owner" }, + { userId: MEMBER2, role: "member" }, + ], + groupIds: ["g-a", "g-scim"], + }); + expect(res.status).toBe(200); + for (const row of store.projectAccess) { + const principals = [row.userId, row.groupId].filter((v) => v !== null); + expect(principals).toHaveLength(1); + } + }); + + it("always stores group rows with role member", async () => { + await putAccess("proj-1", { + users: [{ userId: MEMBER, role: "owner" }], + groupIds: ["g-scim"], + }); + const groupRows = bindings("proj-1").filter((pa) => pa.groupId !== null); + expect(groupRows.map((pa) => pa.groupId)).toEqual(["g-scim"]); + expect(groupRows.every((pa) => pa.role === "member")).toBe(true); + }); + + it("audits counts only — never id arrays", async () => { + const res = await putAccess("proj-1", { + users: [ + { userId: MEMBER, role: "owner" }, + { userId: MEMBER2, role: "member" }, + ], + groupIds: [], + }); + expect(res.status).toBe(200); + expect(store.audits).toHaveLength(1); + expect(store.audits[0]).toMatchObject({ + organizationId: ORG, + projectId: "proj-1", + action: "update", + service: "project", + source: "api", + metadata: { + projectId: "proj-1", + change: "access", + added: 1, + removed: 2, + roleChanged: 0, + }, + }); + for (const value of Object.values(store.audits[0]?.metadata ?? {})) { + expect(Array.isArray(value)).toBe(false); + } + }); + + it("404s a cross-org project BEFORE validating the payload (no existence oracle)", async () => { + const res = await putAccess("proj-x", { + users: [{ userId: OUTSIDER, role: "owner" }], + groupIds: ["g-x"], + }); + expect(res.status).toBe(404); + expect(bindings("proj-x")).toHaveLength(1); + }); + + it("403s a non-manager and writes nothing", async () => { + store.sessionUserId = MEMBER2; + const res = await putAccess( + "proj-2", + { users: [{ userId: MEMBER2, role: "owner" }], groupIds: [] }, + {}, + ); + expect(res.status).toBe(403); + expect(userBinding("proj-2", MEMBER2)?.role).toBe("member"); + expect(store.audits).toHaveLength(0); + }); +}); + +describe("DELETE /v1/projects/:projectId", () => { + it("deletes the project and every child table, in ONE transaction", async () => { + // proj-4 has no bindings and its creator (OWNER) still has proj-2. + // Give it children so the pinned cascade has something to remove — with a + // DISTINCT count per table, so a mis-ordered `Promise.all` destructure in + // the audit metadata cannot pass unnoticed. + const seed = (n: number, push: (id: string) => void) => { + for (let i = 0; i < n; i++) push(`x-${i}`); + }; + seed(1, (id) => store.agents.push({ id: `ag-${id}`, projectId: "proj-4" })); + seed(2, (id) => + store.apiKeys.push({ + id: `k-${id}`, + projectId: "proj-4", + key: `oc_key-4-${id}`, + }), + ); + seed(3, (id) => store.secrets.push({ id: `s-${id}`, projectId: "proj-4" })); + seed(4, (id) => + store.policyRules.push({ id: `pr-${id}`, projectId: "proj-4" }), + ); + seed(5, (id) => + store.policyRulesV2.push({ id: `pv-${id}`, projectId: "proj-4" }), + ); + seed(6, (id) => + store.appConnections.push({ id: `ac-${id}`, projectId: "proj-4" }), + ); + seed(7, (id) => + store.appConfigs.push({ id: `cfg-${id}`, projectId: "proj-4" }), + ); + seed(8, (id) => + store.vaultConnections.push({ id: `vc-${id}`, projectId: "proj-4" }), + ); + seed(9, (id) => store.budgets.push({ id: `b-${id}`, projectId: "proj-4" })); + seed(10, (id) => + store.onboardingSurveys.push({ id: `os-${id}`, projectId: "proj-4" }), + ); + + const res = await remove("proj-4"); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ + id: "proj-4", + name: "Delta", + removed: { + agents: 1, + apiKeys: 2, + secrets: 3, + policyRules: 4, + policyRulesV2: 5, + appConnections: 6, + appConfigs: 7, + vaultConnections: 8, + budgets: 9, + onboardingSurvey: 10, + accessBindings: 0, + }, + }); + + expect(projectRow("proj-4")).toBeUndefined(); + for (const rows of [ + store.agents, + store.apiKeys, + store.secrets, + store.appConnections, + store.appConfigs, + store.policyRules, + store.policyRulesV2, + store.vaultConnections, + store.budgets, + store.onboardingSurveys, + ]) { + expect(rows.some((r) => r.projectId === "proj-4")).toBe(false); + } + // Other projects' rows are untouched. + expect(store.agents.filter((a) => a.projectId === "proj-1")).toHaveLength( + 2, + ); + expect(bindings("proj-1")).toHaveLength(3); + expect(store.txCount).toBe(1); + }); + + it("hands the keys captured BEFORE the delete to the gateway flush", async () => { + store.apiKeys.push({ id: "k-4", projectId: "proj-4", key: "oc_key-4" }); + const res = await remove("proj-4"); + expect(res.status).toBe(200); + expect(flushes.keys).toContainEqual(["oc_key-4"]); + }); + + it("audits with organizationId and NO projectId (the FK would drop the row)", async () => { + const res = await remove("proj-4"); + expect(res.status).toBe(200); + expect(store.audits).toHaveLength(1); + expect(store.audits[0]).toMatchObject({ + organizationId: ORG, + userId: ADMIN, + action: "delete", + service: "project", + source: "api", + metadata: { projectId: "proj-4", name: "Delta" }, + }); + expect(store.audits[0]?.projectId).toBeUndefined(); + }); + + it("409s the organization's last project and deletes nothing", async () => { + store.projects = store.projects.filter( + (p) => p.id === "proj-1" || p.organizationId === OTHER_ORG, + ); + const res = await remove("proj-1"); + expect(res.status).toBe(409); + expect(await res.text()).toContain("at least one project"); + expect(projectRow("proj-1")).toBeTruthy(); + expect(store.audits).toHaveLength(0); + }); + + it("409s when a directly-bound member would be left with no project", async () => { + // proj-1 is MEMBER's only project (created + bound). + const res = await remove("proj-1"); + expect(res.status).toBe(409); + expect(await res.text()).toContain("1 member(s) with no project"); + expect(projectRow("proj-1")).toBeTruthy(); + expect(store.agents.some((a) => a.projectId === "proj-1")).toBe(true); + expect(store.audits).toHaveLength(0); + }); + + it("409s when the only path is a GROUP binding", async () => { + // Give MEMBER another project so only the group-bound MEMBER2 is stranded. + store.projectAccess.push( + access("pa-20", "proj-3", { userId: MEMBER }, "member", at(20)), + ); + // ...and take away MEMBER2's direct binding elsewhere. + store.projectAccess = store.projectAccess.filter((pa) => pa.id !== "pa-5"); + + const res = await remove("proj-1"); + expect(res.status).toBe(409); + expect(await res.text()).toContain("1 member(s) with no project"); + expect(projectRow("proj-1")).toBeTruthy(); + }); + + it("409s with the sharper message when the ACTOR would be stranded", async () => { + store.sessionUserId = MEMBER; // owner binding on proj-1, their only project + const res = await remove("proj-1", {}); + expect(res.status).toBe(409); + expect(await res.text()).toContain("leave you with no project"); + expect(projectRow("proj-1")).toBeTruthy(); + }); + + it("200s when the bound users resolve another project through a BINDING", async () => { + // proj-2's candidates: OWNER (also created proj-4) and MEMBER2 (bound to + // proj-1 through g-a) — `hasResolvableProjectExcluding`'s binding arm. + const res = await remove("proj-2"); + expect(res.status).toBe(200); + expect(projectRow("proj-2")).toBeUndefined(); + }); + + it("200s when a bound user resolves a project they CREATED", async () => { + // The other arm: MEMBER's only path was proj-1 until they create proj-5. + store.projects.push({ + id: "proj-5", + organizationId: ORG, + name: "Epsilon", + slug: "epsilon", + createdByUserId: MEMBER, + createdAt: at(30), + }); + const res = await remove("proj-1"); + expect(res.status).toBe(200); + expect(projectRow("proj-1")).toBeUndefined(); + }); + + it("does not let a SUSPENDED member's binding block the delete", async () => { + const row = store.members.find((m) => m.userId === MEMBER); + if (row) row.status = "suspended"; + // MEMBER (suspended) is the only otherwise-stranded candidate on proj-1. + const res = await remove("proj-1"); + expect(res.status).toBe(200); + expect(projectRow("proj-1")).toBeUndefined(); + }); + + it("404s a project of another organization", async () => { + const res = await remove("proj-x"); + expect(res.status).toBe(404); + expect(projectRow("proj-x")).toBeTruthy(); + expect(store.audits).toHaveLength(0); + }); + + it("403s a non-manager and deletes nothing", async () => { + store.sessionUserId = MEMBER2; + const res = await remove("proj-2", {}); + expect(res.status).toBe(403); + expect(projectRow("proj-2")).toBeTruthy(); + expect(store.audits).toHaveLength(0); + }); +}); diff --git a/packages/api/src/routes/org/projects.ts b/packages/api/src/routes/org/projects.ts new file mode 100644 index 00000000..c6569691 --- /dev/null +++ b/packages/api/src/routes/org/projects.ts @@ -0,0 +1,211 @@ +import { Hono } from "hono"; +import type { Context } from "hono"; +import type { ApiEnv } from "../../types"; +import { auth } from "../../middleware/auth"; +import { ServiceError } from "../../services/errors"; +import { parse } from "./parse"; +import { canAccessProjectAsUser } from "../../middleware/auth/resolve"; +import { + deleteProject, + getProject, + renameProject, + requireManageableProject, + requireProject, +} from "../../services/project-service"; +import { + listProjectAccess, + setProjectAccess, +} from "../../services/project-access-service"; +import { + renameProjectSchema, + setProjectAccessSchema, +} from "../../validations/project"; +import { + withAudit, + AUDIT_ACTIONS, + AUDIT_SERVICES, + AUDIT_SOURCE, +} from "../../services/audit-service"; + +/** + * `/v1/projects/*` — project administration (rename, delete, sharing). + * + * Lives under `routes/org/` because that is the OSS-owned, package-exported + * route folder — the URL is `/v1/projects/...`, NOT `/v1/org/projects`. + * + * The guard stack deliberately DIFFERS from `/v1/org/*`: + * + * `requireProject: false` — the project is named in the path. Demanding an + * `X-Project-Id` header would 401 the OSS web (which sends no headers at all) + * and would introduce a second, conflicting project scope on every request. + * + * NO `role: "admin"` — unlike the org directory, this surface is legitimately + * reachable by a plain member who holds an `owner` binding (13c: the project + * owner may rename / share / delete). Authorization is therefore PER-RESOURCE, + * in the service (`requireManageableProject` / `canAccessProjectAsUser`), never + * in the middleware. + * + * The `scope === "project"` fence is kept for exactly the org routers' reason: + * a project-scoped key is the credential an AGENT carries, and a leaked agent + * key must never be able to rename, delete or re-share the project it lives in. + */ +export const ossProjectRoutes = () => { + const app = new Hono(); + app.use("*", auth({ requireProject: false })); + app.use("*", async (c, next) => { + if (c.get("auth").scope === "project") { + throw new ServiceError( + "FORBIDDEN", + "Managing a project requires a session or an organization-scoped credential.", + ); + } + return next(); + }); + + // `organizationId` on every write is load-bearing, not decoration: besides + // scoping the audit row it flushes the gateway's org cache + // (invalidateGatewayCacheForOrg). ProjectAccess IS the `PrincipalSet` the + // Rust policy engine resolves, so a missed flush is a stale AUTHORIZATION + // decision for as long as the cache window lasts. + const auditBase = (c: Context) => ({ + organizationId: c.get("auth").organizationId, + userId: c.get("auth").userId, + userEmail: c.get("auth").userEmail, + service: AUDIT_SERVICES.PROJECT, + source: AUDIT_SOURCE.API, + }); + + /** Read authorization: anyone who may USE the project may read it. Resolve + * first (404 for unknown/cross-org), then authorize (403). */ + const requireReadableProject = async ( + organizationId: string, + userId: string, + projectId: string, + ) => { + const project = await requireProject(organizationId, projectId); + if ( + !(await canAccessProjectAsUser(userId, { + id: project.id, + organizationId, + })) + ) { + throw new ServiceError( + "FORBIDDEN", + "You do not have access to this project.", + ); + } + return project; + }; + + // GET /projects/:projectId — the sharing page's name/slug source. Nothing + // else in the API exposes a project's name (the session route returns only + // `projectId`). + app.get("/:projectId", async (c) => { + const auth = c.get("auth"); + const projectId = c.req.param("projectId"); + await requireReadableProject(auth.organizationId, auth.userId, projectId); + return c.json(await getProject(auth.organizationId, projectId)); + }); + + // PATCH /projects/:projectId — rename (name only; `slug` is immutable). + app.patch("/:projectId", async (c) => { + const auth = c.get("auth"); + const projectId = c.req.param("projectId"); + await requireManageableProject(auth.organizationId, auth.userId, projectId); + const body = await c.req.json().catch(() => null); + const input = parse(renameProjectSchema, body); + + const project = await withAudit( + () => renameProject(auth.organizationId, projectId, input.name), + (renamed) => ({ + ...auditBase(c), + projectId, + action: AUDIT_ACTIONS.UPDATE, + metadata: { projectId, change: "name", name: renamed.name }, + }), + ); + return c.json(project); + }); + + // DELETE /projects/:projectId — explicit pinned cascade, three refusals. + app.delete("/:projectId", async (c) => { + const auth = c.get("auth"); + const projectId = c.req.param("projectId"); + await requireManageableProject(auth.organizationId, auth.userId, projectId); + + const result = await withAudit( + () => deleteProject(auth.organizationId, auth.userId, projectId), + (deleted) => ({ + ...auditBase(c), + // NO `projectId` here, deliberately: withAudit writes the audit row + // AFTER the delete resolves, so an audit_logs row pointing at the + // just-deleted project violates audit_logs_project_id_fkey — and + // logAuditEvent SWALLOWS its own errors, so the delete would end up + // completely unaudited. `organizationId` keeps it attributable. + action: AUDIT_ACTIONS.DELETE, + // Counts only, never id arrays — audit metadata must stay bounded. + metadata: { projectId, name: deleted.name, removed: deleted.removed }, + }), + ); + + // No flush here: withAudit's `invalidateGatewayCacheForAccount` cannot work + // (it looks keys up by projectId, and they are gone) and a by-key flush is + // impossible once the keys no longer authenticate — so `deleteProject` + // flushes them itself, before the cascade. `organizationId` on the audit + // still flushes every SURVIVING project in the org. + return c.json({ + id: result.id, + name: result.name, + removed: result.removed, + }); + }); + + // GET /projects/:projectId/access — the sharing surface's current bindings. + app.get("/:projectId/access", async (c) => { + const auth = c.get("auth"); + const projectId = c.req.param("projectId"); + await requireReadableProject(auth.organizationId, auth.userId, projectId); + return c.json(await listProjectAccess(auth.organizationId, projectId)); + }); + + // PUT /projects/:projectId/access — bulk replace-set (the dialog's save). + // Returns a JSON body: the client's apiPut ALWAYS parses, so a 204 here + // would throw in the browser. + app.put("/:projectId/access", async (c) => { + const auth = c.get("auth"); + const projectId = c.req.param("projectId"); + const { isOrgAdmin } = await requireManageableProject( + auth.organizationId, + auth.userId, + projectId, + ); + const body = await c.req.json().catch(() => null); + const input = parse(setProjectAccessSchema, body); + + const result = await withAudit( + () => + setProjectAccess( + auth.organizationId, + auth.userId, + isOrgAdmin, + projectId, + input, + ), + (delta) => ({ + ...auditBase(c), + projectId, + action: AUDIT_ACTIONS.UPDATE, + metadata: { + projectId, + change: "access", + added: delta.added, + removed: delta.removed, + roleChanged: delta.roleChanged, + }, + }), + ); + return c.json(result); + }); + + return app; +}; diff --git a/packages/api/src/routes/org/role-mappings.test.ts b/packages/api/src/routes/org/role-mappings.test.ts new file mode 100644 index 00000000..9a17198e --- /dev/null +++ b/packages/api/src/routes/org/role-mappings.test.ts @@ -0,0 +1,1248 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Hono } from "hono"; +import type { ApiEnv } from "../../types"; + +// `/v1/org/role-mappings` end-to-end through the real app: the OSS org routes +// mounted on the `eeRoutes` seam, the OSS role resolver wired as the +// RoleResolver, and `CAPS.rbac` on. Admin callers arrive with an org API key +// (whose key path re-checks admin through the resolver); the non-admin cases +// use a session, since a non-admin's org key fails key authentication +// outright. (Same harness as groups.test.ts — cloned, not shared.) +// +// The apply engine is exercised THROUGH these routes, so the mock has to be +// honest about two things or the security tests prove nothing: the +// `role: { not: "owner" }` predicate on `organizationMember.updateMany`, and +// the difference between the two `$transaction` forms (see the counters). + +const ORG = "org-1"; +const OTHER_ORG = "org-2"; +const OWNER = "user-owner"; +const ADMIN = "user-admin"; +const MEMBER = "user-member"; +const MEMBER2 = "user-member-2"; +const SUSPENDED = "user-suspended"; +const OUTSIDER = "user-outsider"; +const ADMIN_KEY = "oc_org_admin-key"; +const PROJECT_KEY = "oc_project-key-of-owner"; + +vi.hoisted(() => { + process.env.NEXT_PUBLIC_EDITION = "oss"; + process.env.SECRET_ENCRYPTION_KEY = "test-secret"; + process.env.OAUTH_STATE_SECRET = "test-secret"; +}); + +interface MemberRow { + organizationId: string; + userId: string; + userEmail: string; + role: string; + status: string; + ssoExempt: boolean; + suspendedAt: Date | null; + createdAt: Date; +} + +interface UserRow { + id: string; + externalAuthId: string; + email: string; + name: string | null; +} + +interface GroupRow { + id: string; + organizationId: string; + name: string; + source: string; + externalId: string | null; + createdAt: Date; + updatedAt: Date; +} + +interface GroupMemberRow { + groupId: string; + userId: string; + createdAt: Date; +} + +interface MappingRow { + id: string; + organizationId: string; + groupId: string; + role: string; + priority: number; + createdAt: Date; + updatedAt: Date; +} + +interface AuditRow { + organizationId?: string; + userId: string; + userEmail: string; + action: string; + service: string; + source: string; + metadata: Record; +} + +const store = vi.hoisted(() => ({ + members: [] as MemberRow[], + users: [] as UserRow[], + groups: [] as GroupRow[], + groupMembers: [] as GroupMemberRow[], + roleMappings: [] as MappingRow[], + audits: [] as AuditRow[], + seq: 0, + /** Array-form `$transaction` — the APPLY's role writes. */ + txCount: 0, + /** Interactive `$transaction` — the advisory-locked create/reorder. */ + lockedTxCount: 0, + /** Simulate a create-create race: the pre-check misses, create P2002s. */ + race: false, + /** Which user the session provider resolves to (null = no session). */ + sessionUserId: null as string | null, +})); + +vi.mock("@onecli/db", () => { + class PrismaClientKnownRequestError extends Error { + code: string; + constructor(message: string, code: string) { + super(message); + this.code = code; + } + } + + interface GroupWhere { + id?: string; + organizationId?: string; + } + interface GroupMemberWhere { + groupId?: string | { in: string[] }; + userId?: { in: string[] }; + } + interface MappingWhere { + id?: string; + organizationId?: string; + groupId?: string; + } + interface MappingSelect { + id?: boolean; + organizationId?: boolean; + groupId?: boolean; + role?: boolean; + priority?: boolean; + createdAt?: boolean; + updatedAt?: boolean; + group?: { + select: { name?: boolean; _count?: { select: { members?: boolean } } }; + }; + } + interface MemberWhere { + organizationId?: string; + userId?: string | { in: string[] }; + role?: string | { not?: string }; + status?: string | { not?: string }; + } + + const matchesRole = (row: MemberRow, where: MemberWhere) => { + if (where.role === undefined) return true; + if (typeof where.role === "string") return row.role === where.role; + return where.role.not === undefined || row.role !== where.role.not; + }; + + const filterMembers = (where: MemberWhere) => + store.members.filter((row) => { + if ( + where.organizationId !== undefined && + row.organizationId !== where.organizationId + ) + return false; + if (typeof where.userId === "string" && row.userId !== where.userId) + return false; + if ( + typeof where.userId === "object" && + where.userId !== null && + !where.userId.in.includes(row.userId) + ) + return false; + if (where.status !== undefined) { + const ok = + typeof where.status === "string" + ? row.status === where.status + : where.status.not === undefined || row.status !== where.status.not; + if (!ok) return false; + } + return matchesRole(row, where); + }); + + const pickMember = ( + row: MemberRow, + select?: { userId?: boolean; role?: boolean; userEmail?: boolean }, + ) => { + if (!select) return { ...row }; + const picked: Record = {}; + if (select.userId) picked.userId = row.userId; + if (select.role) picked.role = row.role; + if (select.userEmail) picked.userEmail = row.userEmail; + return picked; + }; + + const filterGroupMembers = (where: GroupMemberWhere) => + store.groupMembers.filter((row) => { + if (typeof where.groupId === "string" && row.groupId !== where.groupId) + return false; + if ( + typeof where.groupId === "object" && + where.groupId !== null && + !where.groupId.in.includes(row.groupId) + ) + return false; + if (where.userId !== undefined && !where.userId.in.includes(row.userId)) + return false; + return true; + }); + + const filterMappings = (where: MappingWhere = {}) => + store.roleMappings.filter((row) => { + if (where.id !== undefined && row.id !== where.id) return false; + if ( + where.organizationId !== undefined && + row.organizationId !== where.organizationId + ) + return false; + if (where.groupId !== undefined && row.groupId !== where.groupId) + return false; + return true; + }); + + // The ONE resolution order: priority asc, createdAt asc, id asc. + const sortMappings = (rows: MappingRow[]) => + rows + .slice() + .sort( + (a, b) => + a.priority - b.priority || + a.createdAt.getTime() - b.createdAt.getTime() || + a.id.localeCompare(b.id), + ); + + const pickMapping = (row: MappingRow, select?: MappingSelect) => { + if (!select) return { ...row }; + const picked: Record = {}; + for (const key of [ + "id", + "organizationId", + "groupId", + "role", + "priority", + "createdAt", + "updatedAt", + ] as const) { + if (select[key]) picked[key] = row[key]; + } + if (select.group) { + const group = store.groups.find((g) => g.id === row.groupId); + const relation: Record = {}; + if (select.group.select.name) relation.name = group?.name ?? ""; + if (select.group.select._count) { + relation._count = { + members: store.groupMembers.filter((m) => m.groupId === row.groupId) + .length, + }; + } + picked.group = relation; + } + return picked; + }; + + const dbClient = { + apiKey: { + findUnique: async ({ where }: { where: { key?: string } }) => { + if (where.key === "oc_org_admin-key") + return { + userId: "user-admin", + organizationId: "org-1", + scope: "organization", + }; + // A PROJECT-scoped key owned by the org's OWNER: it authenticates + // fine, which is exactly why the router needs its own scope guard. + if (where.key === "oc_project-key-of-owner") + return { userId: "user-owner", projectId: "proj-1" }; + return null; + }, + findFirst: async () => null, + findMany: async () => [], + }, + user: { + findUnique: async ({ + where, + select, + }: { + where: { id?: string; externalAuthId?: string; email?: string }; + select?: Record; + }) => { + if (select?.organizationMemberships) { + return { + organizationMemberships: store.members + .filter((m) => m.userId === where.id) + .map((m) => ({ organizationId: m.organizationId })), + }; + } + return ( + store.users.find( + (u) => + (where.id !== undefined && u.id === where.id) || + (where.externalAuthId !== undefined && + u.externalAuthId === where.externalAuthId) || + (where.email !== undefined && u.email === where.email), + ) ?? null + ); + }, + }, + organizationMember: { + findUnique: async ({ + where, + select, + }: { + where: { + organizationId_userId: { organizationId: string; userId: string }; + }; + select?: { userId?: boolean; role?: boolean; userEmail?: boolean }; + }) => { + const { organizationId, userId } = where.organizationId_userId; + const row = store.members.find( + (m) => m.organizationId === organizationId && m.userId === userId, + ); + // The role resolver reads role+status off the whole row. + return row ? (select ? pickMember(row, select) : { ...row }) : null; + }, + findFirst: async ({ where }: { where: MemberWhere }) => + filterMembers(where)[0] ?? null, + findMany: async ({ + where, + select, + }: { + where: MemberWhere; + select?: { userId?: boolean; role?: boolean; userEmail?: boolean }; + }) => filterMembers(where).map((row) => pickMember(row, select)), + // THE last-owner invariant at the write layer: the predicate is honoured + // here, or the "a mapping can never strip the last owner" tests would be + // testing the mock rather than the service. + updateMany: async ({ + where, + data, + }: { + where: MemberWhere; + data: { role: string }; + }) => { + const rows = filterMembers(where); + for (const row of rows) row.role = data.role; + return { count: rows.length }; + }, + count: async () => 0, + }, + group: { + findFirst: async ({ + where, + select, + }: { + where: GroupWhere; + select?: { id?: boolean; name?: boolean }; + }) => { + const row = store.groups.find( + (g) => + (where.id === undefined || g.id === where.id) && + (where.organizationId === undefined || + g.organizationId === where.organizationId), + ); + if (!row) return null; + if (!select) return { ...row }; + const picked: Record = {}; + if (select.id) picked.id = row.id; + if (select.name) picked.name = row.name; + return picked; + }, + findMany: async () => [], + }, + groupMember: { + findMany: async ({ + where, + select, + }: { + where: GroupMemberWhere; + select?: { groupId?: boolean; userId?: boolean }; + }) => + filterGroupMembers(where).map((row) => { + if (!select) return { ...row }; + const picked: Record = {}; + if (select.groupId) picked.groupId = row.groupId; + if (select.userId) picked.userId = row.userId; + return picked; + }), + }, + groupRoleMapping: { + findFirst: async ({ + where, + select, + }: { + where: MappingWhere; + select?: MappingSelect; + }) => { + // Race simulation: the create pre-check (a groupId-keyed findFirst + // with no id) misses, so the create itself must surface the P2002. + if (store.race && where.groupId !== undefined && where.id === undefined) + return null; + const row = sortMappings(filterMappings(where))[0]; + return row ? pickMapping(row, select) : null; + }, + findMany: async ({ + where, + select, + }: { + where: MappingWhere; + select?: MappingSelect; + }) => + sortMappings(filterMappings(where)).map((row) => + pickMapping(row, select), + ), + count: async ({ where }: { where: MappingWhere }) => + filterMappings(where).length, + aggregate: async ({ where }: { where: MappingWhere }) => { + const rows = filterMappings(where); + return { + _max: { + priority: rows.length + ? Math.max(...rows.map((r) => r.priority)) + : null, + }, + }; + }, + create: async ({ + data, + select, + }: { + data: { + organizationId: string; + groupId: string; + role: string; + priority: number; + }; + select?: MappingSelect; + }) => { + // `groupId` is @unique — at most one mapping per group. + if (store.roleMappings.some((m) => m.groupId === data.groupId)) { + throw new PrismaClientKnownRequestError( + "Unique constraint failed", + "P2002", + ); + } + const row: MappingRow = { + id: `rm-${++store.seq}`, + organizationId: data.organizationId, + groupId: data.groupId, + role: data.role, + priority: data.priority, + createdAt: new Date(), + updatedAt: new Date(), + }; + store.roleMappings.push(row); + return pickMapping(row, select); + }, + update: async ({ + where, + data, + }: { + where: { id: string }; + data: { priority?: number; role?: string }; + }) => { + const row = store.roleMappings.find((m) => m.id === where.id); + if (!row) + throw new PrismaClientKnownRequestError("Record not found", "P2025"); + if (data.priority !== undefined) row.priority = data.priority; + if (data.role !== undefined) row.role = data.role; + row.updatedAt = new Date(); + return { ...row }; + }, + updateMany: async ({ + where, + data, + }: { + where: MappingWhere; + data: { role?: string; priority?: number }; + }) => { + const rows = filterMappings(where); + for (const row of rows) { + if (data.role !== undefined) row.role = data.role; + if (data.priority !== undefined) row.priority = data.priority; + row.updatedAt = new Date(); + } + return { count: rows.length }; + }, + deleteMany: async ({ where }: { where: MappingWhere }) => { + const rows = filterMappings(where); + const ids = new Set(rows.map((r) => r.id)); + store.roleMappings = store.roleMappings.filter((m) => !ids.has(m.id)); + return { count: rows.length }; + }, + }, + project: { + findFirst: async () => ({ id: "proj-1", organizationId: "org-1" }), + findUnique: async () => ({ id: "proj-1", organizationId: "org-1" }), + }, + projectAccess: { findFirst: async () => null }, + auditLog: { + create: async ({ data }: { data: AuditRow }) => { + store.audits.push(data); + return data; + }, + }, + // Both forms, counted SEPARATELY: the apply batches its role writes as an + // array, while create/reorder open an interactive one to take the + // advisory lock. "No apply transaction was opened" is `txCount`. + $transaction: async (arg: unknown) => { + if (typeof arg === "function") { + store.lockedTxCount++; + return (arg as (tx: unknown) => Promise)(dbClient); + } + store.txCount++; + return Promise.all(arg as Promise[]); + }, + /** The advisory lock — a no-op tagged-template stub. */ + $executeRaw: async () => 1, + }; + + return { + Prisma: { JsonNull: null, PrismaClientKnownRequestError }, + db: dbClient, + }; +}); + +import { createApiApp } from "../../app"; +import { registerOssOrgRoutes } from "./index"; +import { ossRoleResolver } from "../../services/org-role-resolver"; + +const sessionProvider = { + getSession: async () => { + const user = store.users.find((u) => u.id === store.sessionUserId); + return user ? { id: user.externalAuthId, email: user.email } : null; + }, +}; + +const app: Hono = createApiApp(sessionProvider, { + eeRoutes: registerOssOrgRoutes, + roleResolver: ossRoleResolver, +}); + +const at = (minutes: number) => new Date(Date.UTC(2026, 0, 1, 0, minutes)); + +const member = ( + userId: string, + role: string, + createdAt: Date, + organizationId = ORG, +): MemberRow => ({ + organizationId, + userId, + userEmail: `${userId}@example.com`, + role, + status: "active", + ssoExempt: false, + suspendedAt: null, + createdAt, +}); + +const group = ( + id: string, + name: string, + overrides: Partial = {}, +): GroupRow => ({ + id, + organizationId: ORG, + name, + source: "manual", + externalId: null, + createdAt: at(10), + updatedAt: at(10), + ...overrides, +}); + +const mapping = ( + id: string, + groupId: string, + role: string, + priority: number, + organizationId = ORG, +): MappingRow => ({ + id, + organizationId, + groupId, + role, + priority, + createdAt: at(30 + priority), + updatedAt: at(30 + priority), +}); + +beforeEach(() => { + store.users = [ + { + id: OWNER, + externalAuthId: "ext-owner", + email: "owner@example.com", + name: "Olive Owner", + }, + { + id: ADMIN, + externalAuthId: "ext-admin", + email: "admin@example.com", + name: "Adam Admin", + }, + { + id: MEMBER, + externalAuthId: "ext-member", + email: "member@example.com", + name: null, + }, + { + id: MEMBER2, + externalAuthId: "ext-member-2", + email: "member2@example.com", + name: null, + }, + { + id: SUSPENDED, + externalAuthId: "ext-suspended", + email: "suspended@example.com", + name: null, + }, + { + id: OUTSIDER, + externalAuthId: "ext-outsider", + email: "outsider@other.test", + name: "Odette Outsider", + }, + ]; + store.members = [ + member(OWNER, "owner", at(0)), + member(ADMIN, "admin", at(1)), + member(MEMBER, "member", at(2)), + member(MEMBER2, "member", at(3)), + { ...member(SUSPENDED, "member", at(4)), status: "suspended" }, + member(OUTSIDER, "admin", at(5), OTHER_ORG), + ]; + store.groups = [ + group("g-a", "Engineering"), + group("g-b", "Design", { createdAt: at(11), updatedAt: at(11) }), + group("g-c", "Everyone", { createdAt: at(12), updatedAt: at(12) }), + group("g-scim", "Provisioned", { + source: "scim", + externalId: "idp-77", + createdAt: at(13), + updatedAt: at(13), + }), + // A group in a DIFFERENT org — never visible through this org's routes. + group("g-x", "Foreign", { organizationId: OTHER_ORG, createdAt: at(14) }), + ]; + store.groupMembers = [ + { groupId: "g-a", userId: OWNER, createdAt: at(20) }, + { groupId: "g-a", userId: ADMIN, createdAt: at(21) }, + { groupId: "g-a", userId: MEMBER, createdAt: at(22) }, + { groupId: "g-b", userId: MEMBER2, createdAt: at(23) }, + { groupId: "g-b", userId: SUSPENDED, createdAt: at(24) }, + { groupId: "g-c", userId: MEMBER, createdAt: at(25) }, + { groupId: "g-c", userId: MEMBER2, createdAt: at(26) }, + { groupId: "g-scim", userId: MEMBER, createdAt: at(27) }, + { groupId: "g-x", userId: OUTSIDER, createdAt: at(28) }, + ]; + // A CONVERGED baseline: the only mapping grants `member`, which nobody can + // be raised to, so any apply over the untouched fixture is a no-op. + store.roleMappings = [ + mapping("rm-1", "g-b", "member", 0), + mapping("rm-x", "g-x", "admin", 0, OTHER_ORG), + ]; + store.audits = []; + store.seq = 100; + store.txCount = 0; + store.lockedTxCount = 0; + store.race = false; + store.sessionUserId = null; +}); + +const asAdmin = { headers: { Authorization: `Bearer ${ADMIN_KEY}` } }; +const asProjectKey = { headers: { Authorization: `Bearer ${PROJECT_KEY}` } }; + +interface RoleMappingBody { + id: string; + groupId: string; + groupName: string; + role: string; + priority: number; + memberCount: number; + createdAt: string; + updatedAt: string; +} + +const base = "/v1/org/role-mappings"; + +const list = async (init: RequestInit = asAdmin) => + app.request(base, init) as Promise; + +const listRows = async (): Promise => { + const res = await list(); + expect(res.status).toBe(200); + return (await res.json()) as RoleMappingBody[]; +}; + +const create = (body: unknown, init: RequestInit = asAdmin) => + app.request(base, { ...init, method: "POST", body: JSON.stringify(body) }); + +const update = (id: string, body: unknown, init: RequestInit = asAdmin) => + app.request(`${base}/${id}`, { + ...init, + method: "PATCH", + body: JSON.stringify(body), + }); + +const remove = (id: string, init: RequestInit = asAdmin) => + app.request(`${base}/${id}`, { ...init, method: "DELETE" }); + +const reorder = (orderedIds: string[], init: RequestInit = asAdmin) => + app.request(`${base}/order`, { + ...init, + method: "PUT", + body: JSON.stringify({ orderedIds }), + }); + +const preview = (body: unknown, init: RequestInit = asAdmin) => + app.request(`${base}/preview`, { + ...init, + method: "POST", + body: JSON.stringify(body), + }); + +const roleOf = (userId: string) => + store.members.find((m) => m.organizationId === ORG && m.userId === userId) + ?.role; + +const activeOwners = () => + store.members.filter( + (m) => + m.organizationId === ORG && + m.role === "owner" && + m.status !== "suspended", + ); + +const memberAudits = () => store.audits.filter((a) => a.service === "member"); +const mappingAudits = () => + store.audits.filter((a) => a.service === "role-mapping"); + +describe("the guard stack", () => { + it("401s an unauthenticated caller", async () => { + const res = await app.request(base); + expect(res.status).toBe(401); + }); + + it("403s a non-admin member (deterministic, not a 401)", async () => { + store.sessionUserId = MEMBER; + const res = await app.request(base); + expect(res.status).toBe(403); + }); + + it("403s a project-scoped key on EVERY verb, even for an org owner", async () => { + const responses = await Promise.all([ + list(asProjectKey), + create({ groupId: "g-a", role: "admin" }, asProjectKey), + update("rm-1", { role: "admin" }, asProjectKey), + remove("rm-1", asProjectKey), + reorder(["rm-1"], asProjectKey), + preview({ groupId: "g-a", role: "admin" }, asProjectKey), + ]); + for (const res of responses) expect(res.status).toBe(403); + expect(store.audits).toHaveLength(0); + expect(roleOf(MEMBER)).toBe("member"); + }); +}); + +describe("GET /v1/org/role-mappings", () => { + it("returns a BARE ARRAY (not a page envelope) — the client contract", async () => { + const res = await list(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + it("orders by priority ascending and carries groupName + memberCount", async () => { + store.roleMappings.push(mapping("rm-2", "g-a", "admin", 5)); + store.roleMappings.push(mapping("rm-3", "g-c", "member", 2)); + const rows = await listRows(); + expect(rows.map((r) => r.id)).toEqual(["rm-1", "rm-3", "rm-2"]); + expect(rows[0]).toEqual({ + id: "rm-1", + groupId: "g-b", + groupName: "Design", + role: "member", + priority: 0, + memberCount: 2, + createdAt: at(30).toISOString(), + updatedAt: at(30).toISOString(), + }); + }); + + it("never leaks another organization's mappings", async () => { + const rows = await listRows(); + expect(rows.some((r) => r.id === "rm-x")).toBe(false); + }); +}); + +describe("POST /v1/org/role-mappings", () => { + it("creates a mapping, appends at max+1, and audits it", async () => { + const res = await create({ groupId: "g-c", role: "admin" }); + expect(res.status).toBe(200); + const body = (await res.json()) as RoleMappingBody; + expect(body).toMatchObject({ + groupId: "g-c", + groupName: "Everyone", + role: "admin", + priority: 1, // rm-1 sits at 0 + memberCount: 2, + }); + expect(mappingAudits()).toHaveLength(1); + expect(mappingAudits()[0]).toMatchObject({ + organizationId: ORG, + userId: ADMIN, + action: "create", + service: "role-mapping", + source: "api", + metadata: { + mappingId: body.id, + groupId: "g-c", + groupName: "Everyone", + role: "admin", + priority: 1, + // g-c is {MEMBER, MEMBER2}, but MEMBER2 is also in g-b whose `member` + // mapping sits at priority 0 and shadows this one. + rolesChanged: 1, + }, + }); + expect(roleOf(MEMBER)).toBe("admin"); + expect(roleOf(MEMBER2)).toBe("member"); + }); + + it("uses priority 0 for the org's FIRST mapping", async () => { + store.roleMappings = store.roleMappings.filter((m) => m.id === "rm-x"); + const res = await create({ groupId: "g-c", role: "member" }); + expect(res.status).toBe(200); + expect(((await res.json()) as RoleMappingBody).priority).toBe(0); + }); + + it("honours an explicit priority", async () => { + const res = await create({ groupId: "g-c", role: "member", priority: 7 }); + expect(res.status).toBe(200); + expect(((await res.json()) as RoleMappingBody).priority).toBe(7); + }); + + // `priority` is NOT coerced: a body that meant "no priority" must 422, not + // land in slot 0 — the highest-precedence slot, which shadows everything. + it("422s a non-numeric priority instead of reading it as 0", async () => { + for (const priority of [null, "", false, [], "3"]) { + const res = await create({ groupId: "g-c", role: "admin", priority }); + expect(res.status).toBe(422); + } + expect(store.roleMappings.some((m) => m.groupId === "g-c")).toBe(false); + expect(store.audits).toHaveLength(0); + }); + + it("409s once the org is at the mapping ceiling, keeping PUT /order reachable", async () => { + store.roleMappings = Array.from({ length: 500 }, (_, i) => + mapping(`rm-bulk-${i}`, `g-bulk-${i}`, "member", i), + ); + const res = await create({ groupId: "g-a", role: "admin" }); + expect(res.status).toBe(409); + expect(store.roleMappings).toHaveLength(500); + expect(store.audits).toHaveLength(0); + }); + + it("409s a second mapping for the same group (groupId is unique)", async () => { + const res = await create({ groupId: "g-b", role: "admin" }); + expect(res.status).toBe(409); + expect(store.audits).toHaveLength(0); + expect(store.roleMappings.filter((m) => m.groupId === "g-b")).toHaveLength( + 1, + ); + }); + + it("409s a create-create race surfaced as P2002", async () => { + store.race = true; + const res = await create({ groupId: "g-b", role: "admin" }); + expect(res.status).toBe(409); + expect(store.audits).toHaveLength(0); + }); + + it("422s role: owner — mappings can never mint an owner", async () => { + const res = await create({ groupId: "g-c", role: "owner" }); + expect(res.status).toBe(422); + expect(store.audits).toHaveLength(0); + expect(store.roleMappings.some((m) => m.groupId === "g-c")).toBe(false); + }); + + it("422s a malformed body", async () => { + for (const body of [{}, { groupId: "g-c" }, { role: "admin" }, null]) { + expect((await create(body)).status).toBe(422); + } + }); + + it("404s an unknown group and a group of another organization", async () => { + expect((await create({ groupId: "g-nope", role: "admin" })).status).toBe( + 404, + ); + expect((await create({ groupId: "g-x", role: "admin" })).status).toBe(404); + expect(store.roleMappings.filter((m) => m.groupId === "g-x")).toHaveLength( + 1, + ); + }); + + it("MAPS a scim-provisioned group: a mapping is a OneCLI artifact, not an IdP one", async () => { + const res = await create({ groupId: "g-scim", role: "admin" }); + expect(res.status).toBe(200); + expect(((await res.json()) as RoleMappingBody).groupId).toBe("g-scim"); + expect(roleOf(MEMBER)).toBe("admin"); + }); +}); + +describe("PATCH /v1/org/role-mappings/:id", () => { + it("changes the role, audits the discriminator, and re-applies", async () => { + const res = await update("rm-1", { role: "admin" }); + expect(res.status).toBe(200); + const body = (await res.json()) as RoleMappingBody; + expect(body).toMatchObject({ id: "rm-1", role: "admin", priority: 0 }); + expect(mappingAudits()[0]).toMatchObject({ + action: "update", + service: "role-mapping", + metadata: { + mappingId: "rm-1", + groupId: "g-b", + change: "role", + role: "admin", + rolesChanged: 2, + }, + }); + }); + + it("accepts an optional priority and records change: role+priority", async () => { + const res = await update("rm-1", { role: "member", priority: 9 }); + expect(res.status).toBe(200); + expect(((await res.json()) as RoleMappingBody).priority).toBe(9); + expect(mappingAudits()[0]?.metadata).toMatchObject({ + change: "role+priority", + priority: 9, + }); + }); + + it("404s an unknown id and another org's mapping", async () => { + expect((await update("rm-nope", { role: "admin" })).status).toBe(404); + expect((await update("rm-x", { role: "member" })).status).toBe(404); + expect(store.roleMappings.find((m) => m.id === "rm-x")?.role).toBe("admin"); + }); + + it("422s role: owner", async () => { + expect((await update("rm-1", { role: "owner" })).status).toBe(422); + expect(store.roleMappings.find((m) => m.id === "rm-1")?.role).toBe( + "member", + ); + }); +}); + +describe("DELETE /v1/org/role-mappings/:id", () => { + it("removes the row, returns a JSON body, and audits", async () => { + const res = await remove("rm-1"); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("application/json"); + expect(await res.json()).toEqual({ + id: "rm-1", + groupId: "g-b", + role: "member", + rolesChanged: 0, + }); + expect(store.roleMappings.some((m) => m.id === "rm-1")).toBe(false); + expect(mappingAudits()[0]).toMatchObject({ + action: "delete", + service: "role-mapping", + source: "api", + metadata: { mappingId: "rm-1", groupId: "g-b", role: "member" }, + }); + }); + + it("404s an unknown id and another org's mapping", async () => { + expect((await remove("rm-nope")).status).toBe(404); + expect((await remove("rm-x")).status).toBe(404); + expect(store.roleMappings.some((m) => m.id === "rm-x")).toBe(true); + }); + + it("UNSHADOWS: deleting the priority-0 member mapping promotes the suppressed", async () => { + // g-c (Everyone → member) at 0 shadows g-a (Engineering → admin) at 1. + store.roleMappings = [ + mapping("rm-shadow", "g-c", "member", 0), + mapping("rm-eng", "g-a", "admin", 1), + ]; + expect(roleOf(MEMBER)).toBe("member"); + + const res = await remove("rm-shadow"); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ rolesChanged: 1 }); + // MEMBER is in both groups; with the shadow gone the admin mapping wins. + expect(roleOf(MEMBER)).toBe("admin"); + // MEMBER2 is only in g-c, which no longer has a mapping. + expect(roleOf(MEMBER2)).toBe("member"); + }); +}); + +describe("PUT /v1/org/role-mappings/order", () => { + beforeEach(() => { + store.roleMappings = [ + mapping("rm-shadow", "g-c", "member", 0), + mapping("rm-eng", "g-a", "admin", 1), + ]; + }); + + it("reassigns 0..n-1, flips the list, and promotes the unshadowed", async () => { + const res = await reorder(["rm-eng", "rm-shadow"]); + expect(res.status).toBe(200); + const body = (await res.json()) as RoleMappingBody[]; + expect(body.map((r) => r.id)).toEqual(["rm-eng", "rm-shadow"]); + expect(body.map((r) => r.priority)).toEqual([0, 1]); + expect(roleOf(MEMBER)).toBe("admin"); + expect(mappingAudits()[0]).toMatchObject({ + action: "update", + service: "role-mapping", + source: "api", + organizationId: ORG, + metadata: { change: "order", count: 2, rolesChanged: 1 }, + }); + }); + + it("409s a body that does not name every mapping exactly once", async () => { + expect((await reorder(["rm-eng"])).status).toBe(409); + expect((await reorder(["rm-eng", "rm-shadow", "rm-x"])).status).toBe(409); + // Priorities untouched. + expect(store.roleMappings.find((m) => m.id === "rm-eng")?.priority).toBe(1); + expect(store.audits).toHaveLength(0); + }); + + it("422s duplicate ids (malformed, not stale)", async () => { + const res = await reorder(["rm-eng", "rm-eng"]); + expect(res.status).toBe(422); + expect(store.audits).toHaveLength(0); + }); + + it("accepts [] for an org with no mappings", async () => { + store.roleMappings = store.roleMappings.filter( + (m) => m.organizationId !== ORG, + ); + const res = await reorder([]); + expect(res.status).toBe(200); + expect(await res.json()).toEqual([]); + }); + + it("submitting the identical settled order writes nothing", async () => { + const before = store.roleMappings.map((m) => ({ ...m })); + const res = await reorder(["rm-shadow", "rm-eng"]); + expect(res.status).toBe(200); + expect((await res.json()).length).toBe(2); + // No update was issued (updatedAt is byte-identical) and no apply + // transaction was opened. + expect(store.roleMappings).toEqual(before); + expect(store.txCount).toBe(0); + expect(memberAudits()).toHaveLength(0); + }); +}); + +describe("POST /v1/org/role-mappings/preview", () => { + it("counts the raises a new admin mapping would make", async () => { + const res = await preview({ groupId: "g-a", role: "admin" }); + expect(res.status).toBe(200); + // g-a is {OWNER, ADMIN, MEMBER}: the owner is skipped and ADMIN is the + // caller, so only MEMBER is counted. + expect(await res.json()).toEqual({ affectedCount: 1 }); + }); + + it("returns 0 for a member mapping (its effect is shadowing, not demotion)", async () => { + const res = await preview({ groupId: "g-a", role: "member" }); + expect(await res.json()).toEqual({ affectedCount: 0 }); + }); + + it("previews an EXISTING mapping at its current priority, so a shadowed group reads 0", async () => { + // g-c (Everyone → member) sits at priority 0; g-b's mapping is below it, + // and both cover MEMBER2. + store.roleMappings = [ + mapping("rm-shadow", "g-c", "member", 0), + mapping("rm-1", "g-b", "member", 1), + ]; + const shadowed = await preview({ groupId: "g-b", role: "admin" }); + // MEMBER2 is shadowed by g-c; SUSPENDED is only in g-b, so it still counts. + expect(await shadowed.json()).toEqual({ affectedCount: 1 }); + }); + + it("includes suspended members (their stored role is their reinstate shape)", async () => { + const res = await preview({ groupId: "g-b", role: "admin" }); + expect(await res.json()).toEqual({ affectedCount: 2 }); + }); + + it("writes nothing and audits nothing", async () => { + const mappingsBefore = store.roleMappings.map((m) => ({ ...m })); + const membersBefore = store.members.map((m) => ({ ...m })); + const res = await preview({ groupId: "g-a", role: "admin" }); + expect(res.status).toBe(200); + expect(store.roleMappings).toEqual(mappingsBefore); + expect(store.members).toEqual(membersBefore); + expect(store.audits).toHaveLength(0); + expect(store.txCount).toBe(0); + }); + + it("404s a group of another organization (no existence oracle)", async () => { + expect((await preview({ groupId: "g-x", role: "admin" })).status).toBe(404); + expect((await preview({ groupId: "g-nope", role: "admin" })).status).toBe( + 404, + ); + }); + + it("422s role: owner", async () => { + expect((await preview({ groupId: "g-a", role: "owner" })).status).toBe(422); + }); +}); + +describe("applying the mappings", () => { + it("raises every non-owner, non-self member and audits one MEMBER row each", async () => { + const res = await create({ groupId: "g-a", role: "admin" }); + expect(res.status).toBe(200); + expect(roleOf(MEMBER)).toBe("admin"); + // Untouched: the owner (C1) and the acting admin (C2, already admin). + expect(roleOf(OWNER)).toBe("owner"); + expect(roleOf(MEMBER2)).toBe("member"); + + expect(memberAudits()).toHaveLength(1); + expect(memberAudits()[0]).toMatchObject({ + organizationId: ORG, + userId: ADMIN, + userEmail: `${ADMIN}@example.com`, + action: "update", + service: "member", + source: "api", + metadata: { + targetUserId: MEMBER, + role: "admin", + previousRole: "member", + via: "role-mapping", + groupId: "g-a", + trigger: "mapping", + }, + }); + }); + + it("is idempotent: re-applying changes nothing and writes no extra audits", async () => { + const first = await create({ groupId: "g-a", role: "admin" }); + expect(first.status).toBe(200); + const created = (await first.json()) as RoleMappingBody; + expect(memberAudits()).toHaveLength(1); + + store.txCount = 0; + store.audits = []; + const again = await update(created.id, { role: "admin" }); + expect(again.status).toBe(200); + expect(memberAudits()).toHaveLength(0); + // No apply transaction was opened at all. + expect(store.txCount).toBe(0); + expect(mappingAudits()[0]?.metadata).toMatchObject({ rolesChanged: 0 }); + }); + + it("NEVER strips the last owner — create, reorder, or delete", async () => { + const before = store.members.find((m) => m.userId === OWNER); + expect(activeOwners()).toHaveLength(1); + + // The owner is a member of g-a; map it to the weakest role there is. + const created = await create({ groupId: "g-a", role: "member" }); + expect(created.status).toBe(200); + const id = ((await created.json()) as RoleMappingBody).id; + expect(activeOwners()).toHaveLength(1); + + expect((await reorder([id, "rm-1"])).status).toBe(200); + expect(activeOwners()).toHaveLength(1); + + expect((await remove(id)).status).toBe(200); + expect(activeOwners()).toHaveLength(1); + // The owner's row is byte-identical throughout. + expect(store.members.find((m) => m.userId === OWNER)).toEqual(before); + }); + + it("never demotes: a hand-promoted admin in a member-mapped group keeps admin", async () => { + const row = store.members.find((m) => m.userId === MEMBER2); + if (row) row.role = "admin"; + const res = await create({ groupId: "g-c", role: "member" }); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ role: "member" }); + expect(roleOf(MEMBER2)).toBe("admin"); + expect(memberAudits()).toHaveLength(0); + }); + + // The other face of "a mapping is a FLOOR": a hand demotion is NOT a durable + // undo while the mapping is still live. Pinned deliberately — the UI copy and + // the roadmap's decision C both have to say so. + it("re-raises a hand-demoted member who is still in an admin-mapped group", async () => { + const created = await create({ groupId: "g-a", role: "admin" }); + expect(created.status).toBe(200); + const id = ((await created.json()) as RoleMappingBody).id; + expect(roleOf(MEMBER)).toBe("admin"); + + // As `PATCH /v1/org/members/:userId` would: the admin demotes them by hand. + const row = store.members.find((m) => m.userId === MEMBER); + if (row) row.role = "member"; + store.audits = []; + + // ANY later apply — here a reorder — puts the role straight back, because + // MEMBER is still a member of the still-mapped group. + expect((await reorder([id, "rm-1"])).status).toBe(200); + expect(roleOf(MEMBER)).toBe("admin"); + expect(memberAudits()).toHaveLength(1); + expect(memberAudits()[0]).toMatchObject({ + metadata: { targetUserId: MEMBER, role: "admin", via: "role-mapping" }, + }); + + // The durable remedy: drop the mapping FIRST, then demote. + expect((await remove(id)).status).toBe(200); + if (row) row.role = "member"; + expect((await reorder(["rm-1"])).status).toBe(200); + expect(roleOf(MEMBER)).toBe("member"); + }); + + it("raises a SUSPENDED member too (their stored role is their reinstate shape)", async () => { + const res = await update("rm-1", { role: "admin" }); + expect(res.status).toBe(200); + expect(roleOf(SUSPENDED)).toBe("admin"); + expect(store.members.find((m) => m.userId === SUSPENDED)?.status).toBe( + "suspended", + ); + expect( + memberAudits() + .map((a) => a.metadata.targetUserId) + .sort(), + ).toEqual([MEMBER2, SUSPENDED].sort()); + }); + + it("keeps mapping audit metadata bounded — counts only, never id arrays", async () => { + const res = await create({ groupId: "g-a", role: "admin" }); + expect(res.status).toBe(200); + for (const audit of mappingAudits()) { + expect(audit.organizationId).toBe(ORG); + expect(audit.source).toBe("api"); + for (const value of Object.values(audit.metadata)) { + expect(Array.isArray(value)).toBe(false); + } + } + }); + + it("never touches another organization's members", async () => { + const res = await create({ groupId: "g-a", role: "admin" }); + expect(res.status).toBe(200); + expect( + store.members.find((m) => m.organizationId === OTHER_ORG)?.role, + ).toBe("admin"); + }); +}); diff --git a/packages/api/src/routes/org/role-mappings.ts b/packages/api/src/routes/org/role-mappings.ts new file mode 100644 index 00000000..76ab4c2b --- /dev/null +++ b/packages/api/src/routes/org/role-mappings.ts @@ -0,0 +1,200 @@ +import { Hono } from "hono"; +import type { Context } from "hono"; +import type { ApiEnv } from "../../types"; +import { auth } from "../../middleware/auth"; +import { ServiceError } from "../../services/errors"; +import { parse } from "./parse"; +import { + createOrgRoleMapping, + deleteOrgRoleMapping, + listOrgRoleMappings, + previewOrgRoleMapping, + reorderOrgRoleMappings, + updateOrgRoleMapping, +} from "../../services/org-role-mapping-service"; +import { + createRoleMappingSchema, + previewRoleMappingSchema, + reorderRoleMappingsSchema, + updateRoleMappingSchema, +} from "../../validations/org"; +import { + withAudit, + AUDIT_ACTIONS, + AUDIT_SERVICES, + AUDIT_SOURCE, +} from "../../services/audit-service"; + +/** + * `/v1/org/role-mappings` — group → org-role mappings. + * + * Same guard stack as `/v1/org/groups`, for the same reasons: + * + * `requireProject: false`: these are ORG-scoped routes, so a caller with no + * project context (an org API key without `X-Project-Id`) must still get + * through. `role: "admin"` makes the whole router admin-only — a plain member + * gets a deterministic 403, which is exactly what the web client expects + * (directory queries are not retried on 403). + * + * `role` alone is SCOPE-BLIND, so it is not sufficient on its own: a + * project-scoped key (the credential an agent carries) resolves to its owning + * user, and if that user happens to be an org admin the role check passes. + * Here that is at its sharpest — a leaked agent key would be able to rewrite + * WHO IS AN ORG ADMIN, minting the very authority the guard exists to + * protect. Org-wide authority requires an org-wide credential, so + * project-scoped callers are rejected outright. + */ +export const orgRoleMappingRoutes = () => { + const app = new Hono(); + app.use("*", auth({ requireProject: false, role: "admin" })); + app.use("*", async (c, next) => { + if (c.get("auth").scope === "project") { + throw new ServiceError( + "FORBIDDEN", + "Organization management requires an organization-scoped credential.", + ); + } + return next(); + }); + + // `organizationId` in every audit params below is deliberate: besides + // scoping the audit row it flushes the gateway's org cache + // (invalidateGatewayCacheForOrg). A mapping write can change a member's org + // role, which is exactly what authorization reads — a missed flush becomes a + // stale authorization decision. The MEMBER audit rows the apply writes + // deliberately do NOT flush again; this one flush covers the request. + const auditBase = (c: Context) => ({ + organizationId: c.get("auth").organizationId, + userId: c.get("auth").userId, + userEmail: c.get("auth").userEmail, + service: AUDIT_SERVICES.ROLE_MAPPING, + source: AUDIT_SOURCE.API, + }); + + // GET /org/role-mappings — the WHOLE ordered set as a BARE ARRAY (not a + // DirectoryPage): `groupId` is unique so the set is bounded by group count, + // the client types it as `RoleMappingRow[]`, and resolution needs all of it. + app.get("/", async (c) => { + const auth = c.get("auth"); + return c.json(await listOrgRoleMappings(auth.organizationId)); + }); + + // Literal paths are registered BEFORE any parameterized path of the same + // method (`/preview` before `/`, `/order` ahead of a future `PUT /:id`). + // Nothing collides by method today; keeping the order is the convention + // that stops the first such addition from being silently shadowed. + + // POST /org/role-mappings/preview — dry run. Deliberately NOT wrapped in + // withAudit: it writes nothing, so auditing it would both log a phantom + // change and wrongly flush the gateway org cache. + app.post("/preview", async (c) => { + const auth = c.get("auth"); + const body = await c.req.json().catch(() => null); + const input = parse(previewRoleMappingSchema, body); + return c.json( + await previewOrgRoleMapping(auth.organizationId, auth.userId, input), + ); + }); + + // PUT /org/role-mappings/order — reassign priorities from the FULL ordered + // id set. Returns the new order as a JSON body: the client's apiPut ALWAYS + // parses, so a 204 here would throw in the browser. + app.put("/order", async (c) => { + const auth = c.get("auth"); + const body = await c.req.json().catch(() => null); + const input = parse(reorderRoleMappingsSchema, body); + + const result = await withAudit( + () => + reorderOrgRoleMappings( + auth.organizationId, + auth.userId, + input.orderedIds, + ), + (r) => ({ + ...auditBase(c), + action: AUDIT_ACTIONS.UPDATE, + // Counts only, never id arrays — audit metadata must stay bounded. + metadata: { + change: "order", + count: r.mappings.length, + rolesChanged: r.rolesChanged, + }, + }), + ); + return c.json(result.mappings); + }); + + // POST /org/role-mappings — create (at most one per group: 409 otherwise). + app.post("/", async (c) => { + const auth = c.get("auth"); + const body = await c.req.json().catch(() => null); + const input = parse(createRoleMappingSchema, body); + + const result = await withAudit( + () => createOrgRoleMapping(auth.organizationId, auth.userId, input), + (r) => ({ + ...auditBase(c), + action: AUDIT_ACTIONS.CREATE, + metadata: { + mappingId: r.mapping.id, + groupId: r.mapping.groupId, + groupName: r.mapping.groupName, + role: r.mapping.role, + priority: r.mapping.priority, + rolesChanged: r.rolesChanged, + }, + }), + ); + return c.json(result.mapping); + }); + + // PATCH /org/role-mappings/:id — change the role and/or the priority. + app.patch("/:id", async (c) => { + const auth = c.get("auth"); + const id = c.req.param("id"); + const body = await c.req.json().catch(() => null); + const input = parse(updateRoleMappingSchema, body); + + const result = await withAudit( + () => updateOrgRoleMapping(auth.organizationId, auth.userId, id, input), + (r) => ({ + ...auditBase(c), + action: AUDIT_ACTIONS.UPDATE, + metadata: { + mappingId: r.mapping.id, + groupId: r.mapping.groupId, + change: input.priority !== undefined ? "role+priority" : "role", + role: r.mapping.role, + priority: r.mapping.priority, + rolesChanged: r.rolesChanged, + }, + }), + ); + return c.json(result.mapping); + }); + + // DELETE /org/role-mappings/:id — the response carries a JSON body like + // every other route here (apiDelete discards it; the error path parses). + app.delete("/:id", async (c) => { + const auth = c.get("auth"); + const id = c.req.param("id"); + + const result = await withAudit( + () => deleteOrgRoleMapping(auth.organizationId, auth.userId, id), + (r) => ({ + ...auditBase(c), + action: AUDIT_ACTIONS.DELETE, + metadata: { + mappingId: r.id, + groupId: r.groupId, + role: r.role, + rolesChanged: r.rolesChanged, + }, + }), + ); + return c.json(result); + }); + + return app; +}; diff --git a/packages/api/src/services/audit-service.ts b/packages/api/src/services/audit-service.ts index c5db2cf5..1c4e7486 100644 --- a/packages/api/src/services/audit-service.ts +++ b/packages/api/src/services/audit-service.ts @@ -59,7 +59,10 @@ export const AUDIT_SERVICES = { // ACCEPTANCE is deliberately not here: accepting creates a membership, so it // audits as a MEMBER create with `via: "invitation"` metadata. INVITATION: "invitation", - // EE-only (directory): human groups (manual + SCIM-provisioned) + // Directory: human groups. OSS writes them via `/v1/org/groups` (create / + // rename / delete; membership changes audit as UPDATE with + // `change: "members"` — there is deliberately no GROUP_MEMBER service, + // matching the INVITATION→MEMBER precedent); EE adds SCIM-provisioned writes. GROUP: "group", // EE-only (directory): group→org-role mappings (the mapping config itself; // the member role changes it drives are audited under MEMBER). diff --git a/packages/api/src/services/org-group-service.ts b/packages/api/src/services/org-group-service.ts new file mode 100644 index 00000000..f82409d5 --- /dev/null +++ b/packages/api/src/services/org-group-service.ts @@ -0,0 +1,557 @@ +import { db, Prisma } from "@onecli/db"; +import { ServiceError } from "./errors"; +import { + clampDirectoryLimit, + decodeCursor, + toDirectoryPage, + type DirectoryPage, +} from "../lib/cursor"; +import type { GroupListQuery } from "../validations/org"; +import { + applyOrgRoleMappings, + applyRoleMappingsForGroup, +} from "./org-role-mapping-service"; + +// The org's human-group directory: list/create/rename/delete plus the three +// membership writers. Scoped to ONE organization on every call — the caller's +// `auth.organizationId`, never a body/query parameter — so this can never +// read or write across orgs. +// +// `source` is read-only: creates hard-code "manual", and "scim" rows +// (IdP-provisioned in EE) reject every mutation with 409 — the dashboard must +// never fight the IdP over a provisioned group. + +/** One row of the groups directory (matches the client's `GroupRow`). */ +export interface GroupListRow { + id: string; + name: string; + source: string; + externalId: string | null; + memberCount: number; + createdAt: string; + updatedAt: string; +} + +/** One row of a group's member list (matches the client's `GroupMemberRow`). */ +export interface GroupMemberListRow { + userId: string; + email: string; + name: string | null; + addedAt: string; +} + +/** What a delete actually removed — the cascade impact, read BEFORE the delete. */ +export interface GroupDeleteResult { + id: string; + name: string; + removedMembers: number; + removedProjectBindings: number; + removedRoleMappings: number; +} + +export type ListOrgGroupsParams = Partial; + +/** + * Same keyset shape as the invitations directory: ordered `createdAt asc, + * id asc` and paged by that exact two-part key, since `createdAt` alone is + * not unique. + */ +const CURSOR_PARTS = 2; + +const cursorFilter = (raw: string | undefined) => { + const parts = decodeCursor(raw, CURSOR_PARTS); + if (!parts) return undefined; + const [createdAtIso, id] = parts; + if (createdAtIso === undefined || id === undefined) return undefined; + const createdAt = new Date(createdAtIso); + // A cursor whose timestamp half is not a date is malformed — serve page one + // rather than handing an Invalid Date to the query layer. + if (Number.isNaN(createdAt.getTime())) return undefined; + return { + OR: [{ createdAt: { gt: createdAt } }, { createdAt, id: { gt: id } }], + }; +}; + +/** Member pages are keyed `createdAt asc, userId asc` (composite PK, no id). */ +const memberCursorFilter = (raw: string | undefined) => { + const parts = decodeCursor(raw, CURSOR_PARTS); + if (!parts) return undefined; + const [createdAtIso, userId] = parts; + if (createdAtIso === undefined || userId === undefined) return undefined; + const createdAt = new Date(createdAtIso); + if (Number.isNaN(createdAt.getTime())) return undefined; + return { + OR: [ + { createdAt: { gt: createdAt } }, + { createdAt, userId: { gt: userId } }, + ], + }; +}; + +export const listOrgGroups = async ( + organizationId: string, + params: ListOrgGroupsParams = {}, +): Promise> => { + const limit = clampDirectoryLimit(params.limit); + const after = cursorFilter(params.cursor); + const q = params.q?.trim(); + + const rows = await db.group.findMany({ + where: { + organizationId, + ...(params.source ? { source: params.source } : {}), + ...(q ? { name: { contains: q, mode: "insensitive" as const } } : {}), + // The keyset predicate lives under AND, never as a top-level `OR` spread: + // a future filter that also needs `OR` would otherwise overwrite the + // cursor clause and silently restart pagination from the first page. + ...(after ? { AND: [after] } : {}), + }, + select: { + id: true, + name: true, + source: true, + externalId: true, + createdAt: true, + updatedAt: true, + _count: { select: { members: true } }, + }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + take: limit + 1, + }); + + const groups: GroupListRow[] = rows.map((row) => ({ + id: row.id, + name: row.name, + source: row.source, + externalId: row.externalId, + memberCount: row._count.members, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + })); + + return toDirectoryPage(groups, limit, (row) => [row.createdAt, row.id]); +}; + +/** + * Resolve a group WITHIN the caller's org — always + * `findFirst({ id, organizationId })`, NEVER `findUnique({ where: { id } })`: + * a cross-org id must read as absent (404), not leak another org's row. + */ +const requireGroup = async (organizationId: string, groupId: string) => { + const group = await db.group.findFirst({ + where: { id: groupId, organizationId }, + select: { id: true, name: true, source: true }, + }); + if (!group) throw new ServiceError("NOT_FOUND", "Group not found."); + return group; +}; + +/** Mutations additionally require manual provenance ("scim" rows are IdP-owned). */ +const requireManualGroup = async (organizationId: string, groupId: string) => { + const group = await requireGroup(organizationId, groupId); + if (group.source !== "manual") { + throw new ServiceError( + "CONFLICT", + "This group is managed by your identity provider and cannot be changed here.", + ); + } + return group; +}; + +const isUniqueViolation = (err: unknown) => + err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002"; + +export const createOrgGroup = async ( + organizationId: string, + name: string, +): Promise => { + // Friendly pre-check for the common case; the P2002 catch below covers the + // create-create race the pre-check cannot see. + const dupe = await db.group.findFirst({ + where: { organizationId, name }, + select: { id: true }, + }); + if (dupe) { + throw new ServiceError( + "CONFLICT", + "A group with this name already exists.", + ); + } + + try { + const row = await db.group.create({ + // `source` is hard-coded: a create can never mint a "scim" row. + data: { organizationId, name, source: "manual" }, + select: { + id: true, + name: true, + source: true, + externalId: true, + createdAt: true, + updatedAt: true, + }, + }); + return { + id: row.id, + name: row.name, + source: row.source, + externalId: row.externalId, + memberCount: 0, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + } catch (err) { + if (isUniqueViolation(err)) { + throw new ServiceError( + "CONFLICT", + "A group with this name already exists.", + ); + } + throw err; + } +}; + +export const renameOrgGroup = async ( + organizationId: string, + groupId: string, + name: string, +): Promise => { + const group = await requireManualGroup(organizationId, groupId); + + // Rename-to-self is a permitted no-op (a 409 here would make the rename + // dialog's "Save without edits" an error). + if (group.name !== name) { + const dupe = await db.group.findFirst({ + where: { organizationId, name, id: { not: groupId } }, + select: { id: true }, + }); + if (dupe) { + throw new ServiceError( + "CONFLICT", + "A group with this name already exists.", + ); + } + } + + // Org-scoped conditional write (the delete path's deleteMany pattern) — a + // count of 0 means the row vanished between the check and the write, which + // is a 404, not the P2025 500 a bare update() would surface. + try { + const { count } = await db.group.updateMany({ + where: { id: groupId, organizationId }, + data: { name }, + }); + if (count === 0) throw new ServiceError("NOT_FOUND", "Group not found."); + } catch (err) { + if (isUniqueViolation(err)) { + throw new ServiceError( + "CONFLICT", + "A group with this name already exists.", + ); + } + throw err; + } + + const row = await db.group.findFirst({ + where: { id: groupId, organizationId }, + select: { + id: true, + name: true, + source: true, + externalId: true, + createdAt: true, + updatedAt: true, + _count: { select: { members: true } }, + }, + }); + if (!row) throw new ServiceError("NOT_FOUND", "Group not found."); + return { + id: row.id, + name: row.name, + source: row.source, + externalId: row.externalId, + memberCount: row._count.members, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +}; + +/** + * Delete a group. The DB cascades take everything down with the row — + * GroupMember, the group's ProjectAccess bindings, its GroupRoleMapping, and + * PolicyRuleIdentity.group rows — so the impact is read FIRST and returned: + * the project-access cascade is a SILENT access revocation, and the confirm + * dialog must be able to say what went with the group. + * + * NOTE (reconciliation Stage C): the OSS grants engine treats a rule identity + * orphaned by an FK cascade as INERT — it is skipped at compile time, never + * widened to "any principal" (see grants-service). So a group delete needs no + * explicit orphan-neutralization pass here; the identity rows simply cascade + * away and the rules that referenced them lose one target. Role automation + * (group→org-role mappings), re-added in Stage D, IS re-resolved here: the + * cascade takes this group's GroupRoleMapping with it, which can unshadow a + * lower-priority mapping, so a mapping re-resolution runs after the delete — + * guarded on the selected roleMapping, org-wide, trigger "group-deleted", and + * raise-only, so it is never a demotion (see below). + */ +export const deleteOrgGroup = async ( + organizationId: string, + actorUserId: string, + groupId: string, +): Promise => { + const group = await db.group.findFirst({ + where: { id: groupId, organizationId }, + select: { + id: true, + name: true, + source: true, + _count: { select: { members: true, projectAccess: true } }, + roleMapping: { select: { id: true } }, + }, + }); + if (!group) throw new ServiceError("NOT_FOUND", "Group not found."); + if (group.source !== "manual") { + throw new ServiceError( + "CONFLICT", + "This group is managed by your identity provider and cannot be changed here.", + ); + } + + // Org-scoped conditional delete: a count of 0 means the row vanished (or + // never belonged to this org) between the read and the write — 404 either + // way, never a cross-org delete. The DB cascades (GroupMember, ProjectAccess, + // GroupRoleMapping, PolicyRuleIdentity) run with the row. + const { count } = await db.group.deleteMany({ + where: { id: groupId, organizationId }, + }); + if (count === 0) throw new ServiceError("NOT_FOUND", "Group not found."); + + // The cascade took this group's mapping with it, which can UNSHADOW a + // lower-priority mapping (e.g. the deleted group was the priority-0 `member` + // mapping suppressing an `admin` one), so the whole org is re-resolved — + // group-scoped would be wrong here, the group is gone. Guarded on the + // mapping the impact read already selected, so the overwhelmingly common + // "delete an unmapped group" path costs nothing extra. Roles are never + // REVERTED by a delete: nothing in this system lowers a role (decision C). + // The trigger is its own value: the cause is a mapping cascade, not a + // membership edit, and the whole point of the discriminator is letting an + // operator split those apart in the MEMBER audit rows. + if (group.roleMapping) { + await applyOrgRoleMappings(organizationId, actorUserId, "group-deleted"); + } + + return { + id: group.id, + name: group.name, + removedMembers: group._count.members, + removedProjectBindings: group._count.projectAccess, + removedRoleMappings: group.roleMapping ? 1 : 0, + }; +}; + +export interface ListOrgGroupMembersParams { + limit?: number; + cursor?: string; + q?: string; +} + +export const listOrgGroupMembers = async ( + organizationId: string, + groupId: string, + params: ListOrgGroupMembersParams = {}, +): Promise> => { + await requireGroup(organizationId, groupId); + const limit = clampDirectoryLimit(params.limit); + const after = memberCursorFilter(params.cursor); + const q = params.q?.trim(); + + const rows = await db.groupMember.findMany({ + where: { + groupId, + ...(q + ? { + user: { + OR: [ + { email: { contains: q, mode: "insensitive" as const } }, + { name: { contains: q, mode: "insensitive" as const } }, + ], + }, + } + : {}), + // Keyset predicate under AND — see listOrgGroups. + ...(after ? { AND: [after] } : {}), + }, + select: { + userId: true, + createdAt: true, + user: { select: { email: true, name: true } }, + }, + orderBy: [{ createdAt: "asc" }, { userId: "asc" }], + take: limit + 1, + }); + + const members: GroupMemberListRow[] = rows.map((row) => ({ + userId: row.userId, + email: row.user.email, + name: row.user.name, + addedAt: row.createdAt.toISOString(), + })); + + return toDirectoryPage(members, limit, (row) => [row.addedAt, row.userId]); +}; + +/** + * THE security invariant of every membership write: `GroupMember.userId` FKs + * the GLOBAL `User` table, so the org scope exists ONLY in this check. Every + * id must resolve to a member of the caller's org — one foreign id in the set + * and the whole write is rejected, or a group could capture users from + * another organization. + * + * Suspended members are deliberately allowed: suspension is an AUTH-time + * gate, and stripping group rows on suspend would silently rewrite the + * member's access shape on reinstate. + */ +const assertOrgMembers = async (organizationId: string, userIds: string[]) => { + if (userIds.length === 0) return; + const rows = await db.organizationMember.findMany({ + where: { organizationId, userId: { in: userIds } }, + select: { userId: true }, + }); + const known = new Set(rows.map((row) => row.userId)); + if (userIds.some((id) => !known.has(id))) { + throw new ServiceError( + "BAD_REQUEST", + "One or more users are not members of this organization.", + ); + } +}; + +// Group→role mappings are re-resolved after every membership write (cloud +// applies them at SSO login; OSS has no SSO, so the write IS the trigger). +// They are a FLOOR: a mapping can only ever RAISE a member's org role (see +// org-role-mapping-service.ts, decision C), so a removal almost never changes +// a role — but the remove paths call the same hook anyway. Keeping the seam +// one uniform shape is the point: an edition that flips to two-way sync would +// otherwise silently skip the path that matters most. The cost is one indexed +// lookup on `group_role_mappings`. +// +// REMOVALS ARE UNSHADOWED. The group-scoped apply resolves the group's members +// as they are AFTER the write, so the removal paths hand it the ids they just +// removed (`applyRoleMappingsForGroup(orgId, groupId, actorId, removedIds)`). +// Without that a user held down by a high-priority `member` mapping on this +// group, while a lower-priority `admin` mapping also covered them, would stay +// under-privileged after leaving it — the same unshadow decision E handles +// org-wide for `deleteOrgGroup`. Adders pass nothing: their ids are already in +// the group. +// +// Each call happens AFTER its own write commits, never inside the +// transaction — the apply writes member rows and audit rows of its own. + +/** + * Replace the group's member set. Returns the honest delta; a no-delta call + * returns `{ added: 0, removed: 0 }` WITHOUT opening a transaction (and the + * route's audit/flush still runs — cheap, and simpler than making withAudit + * conditional). + */ +export const setOrgGroupMembers = async ( + organizationId: string, + actorUserId: string, + groupId: string, + userIds: string[], +): Promise<{ added: number; removed: number }> => { + await requireManualGroup(organizationId, groupId); + + const targetIds = [...new Set(userIds)]; + await assertOrgMembers(organizationId, targetIds); + + const target = new Set(targetIds); + const currentRows = await db.groupMember.findMany({ + where: { groupId }, + select: { userId: true }, + }); + const current = new Set(currentRows.map((row) => row.userId)); + + const toAdd = [...target].filter((id) => !current.has(id)); + const toRemove = [...current].filter((id) => !target.has(id)); + + if (toAdd.length === 0 && toRemove.length === 0) { + return { added: 0, removed: 0 }; + } + + await db.$transaction([ + db.groupMember.deleteMany({ + where: { groupId, userId: { in: toRemove } }, + }), + // skipDuplicates makes a concurrent double-add idempotent (composite PK) + // instead of surfacing a P2002. + db.groupMember.createMany({ + data: toAdd.map((userId) => ({ + groupId, + userId, + createdByUserId: actorUserId, + })), + skipDuplicates: true, + }), + ]); + + await applyRoleMappingsForGroup( + organizationId, + groupId, + actorUserId, + toRemove, + ); + + return { added: toAdd.length, removed: toRemove.length }; +}; + +/** + * Idempotent single add (the scripting surface). `added` is honest: false + * when the membership already existed. + */ +export const addOrgGroupMember = async ( + organizationId: string, + actorUserId: string, + groupId: string, + userId: string, +): Promise<{ added: boolean }> => { + await requireManualGroup(organizationId, groupId); + await assertOrgMembers(organizationId, [userId]); + + const existing = await db.groupMember.findUnique({ + where: { groupId_userId: { groupId, userId } }, + select: { userId: true }, + }); + + await db.groupMember.upsert({ + where: { groupId_userId: { groupId, userId } }, + create: { groupId, userId, createdByUserId: actorUserId }, + update: {}, + }); + + await applyRoleMappingsForGroup(organizationId, groupId, actorUserId); + + return { added: !existing }; +}; + +/** + * Idempotent single remove: a missing membership is NOT a 404 (`removed: + * false`) — only a missing/cross-org GROUP is. + */ +export const removeOrgGroupMember = async ( + organizationId: string, + actorUserId: string, + groupId: string, + userId: string, +): Promise<{ removed: boolean }> => { + await requireManualGroup(organizationId, groupId); + + const { count } = await db.groupMember.deleteMany({ + where: { groupId, userId }, + }); + + await applyRoleMappingsForGroup(organizationId, groupId, actorUserId, [ + userId, + ]); + + return { removed: count > 0 }; +}; diff --git a/packages/api/src/services/org-role-mapping-service.test.ts b/packages/api/src/services/org-role-mapping-service.test.ts new file mode 100644 index 00000000..5fca0c61 --- /dev/null +++ b/packages/api/src/services/org-role-mapping-service.test.ts @@ -0,0 +1,254 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// The pure resolver behind group→role mappings. No DB mock at all (the +// org-role-resolver.test.ts / policy-target.test.ts precedent): this function +// decides who gets promoted, so a wrong answer here is a privilege escalation +// or a silent demotion. Every decision letter it encodes is pinned below. + +vi.hoisted(() => { + process.env.NEXT_PUBLIC_EDITION = "oss"; +}); + +const warn = vi.hoisted(() => vi.fn()); + +vi.mock("../lib/logger", () => ({ + logger: { child: () => ({ warn }) }, +})); + +vi.mock("@onecli/db", () => ({ Prisma: {}, db: {} })); + +import { + resolveRoleMappingChanges, + type MappingRule, + type MemberState, + type RoleChange, +} from "./org-role-mapping-service"; + +const ACTOR = "user-actor"; + +const at = (minutes: number) => new Date(Date.UTC(2026, 0, 1, 0, minutes)); + +const rule = ( + id: string, + groupId: string, + role: string, + priority: number, + createdAt = at(priority), +): MappingRule => ({ id, groupId, role, priority, createdAt }); + +const member = (userId: string, role: string): MemberState => ({ + userId, + role, + userEmail: `${userId}@example.com`, +}); + +const groups = (entries: Record) => + new Map(Object.entries(entries).map(([g, ids]) => [g, new Set(ids)])); + +const resolve = (input: { + mappings: MappingRule[]; + membersByGroup: Map>; + candidates: MemberState[]; + actorUserId?: string; +}): RoleChange[] => + resolveRoleMappingChanges({ + mappings: input.mappings, + membersByGroup: input.membersByGroup, + candidates: input.candidates, + actorUserId: input.actorUserId ?? ACTOR, + }); + +beforeEach(() => { + warn.mockClear(); +}); + +describe("resolveRoleMappingChanges", () => { + it("returns nothing when the org has no mappings", () => { + expect( + resolve({ + mappings: [], + membersByGroup: groups({ "g-a": ["u-1"] }), + candidates: [member("u-1", "member")], + }), + ).toEqual([]); + }); + + it("raises a member through a single admin mapping", () => { + const changes = resolve({ + mappings: [rule("rm-1", "g-a", "admin", 0)], + membersByGroup: groups({ "g-a": ["u-1"] }), + candidates: [member("u-1", "member")], + }); + expect(changes).toEqual([ + { + userId: "u-1", + userEmail: "u-1@example.com", + from: "member", + to: "admin", + mappingId: "rm-1", + groupId: "g-a", + }, + ]); + }); + + it("raises nobody through a member mapping (everyone is already >= member)", () => { + expect( + resolve({ + mappings: [rule("rm-1", "g-a", "member", 0)], + membersByGroup: groups({ "g-a": ["u-1", "u-2"] }), + candidates: [member("u-1", "member"), member("u-2", "member")], + }), + ).toEqual([]); + }); + + it("lets a member mapping at priority 0 SHADOW an admin mapping at priority 1", () => { + // The load-bearing case: role strength plays no part in picking the + // winner, only explicit order does (decision A3/C5). + expect( + resolve({ + mappings: [ + rule("rm-shadow", "g-everyone", "member", 0), + rule("rm-eng", "g-eng", "admin", 1), + ], + membersByGroup: groups({ + "g-everyone": ["u-1"], + "g-eng": ["u-1"], + }), + candidates: [member("u-1", "member")], + }), + ).toEqual([]); + }); + + it("...and reordering the admin mapping to priority 0 raises them", () => { + const changes = resolve({ + mappings: [ + rule("rm-eng", "g-eng", "admin", 0), + rule("rm-shadow", "g-everyone", "member", 1), + ], + membersByGroup: groups({ "g-everyone": ["u-1"], "g-eng": ["u-1"] }), + candidates: [member("u-1", "member")], + }); + expect(changes).toHaveLength(1); + expect(changes[0]).toMatchObject({ to: "admin", mappingId: "rm-eng" }); + }); + + it("breaks a priority tie by createdAt asc, then id asc", () => { + const older = resolve({ + mappings: [ + rule("rm-b", "g-b", "admin", 0, at(9)), + rule("rm-a", "g-a", "member", 0, at(5)), + ], + membersByGroup: groups({ "g-a": ["u-1"], "g-b": ["u-1"] }), + candidates: [member("u-1", "member")], + }); + // rm-a is older, so its `member` wins and nothing is raised. + expect(older).toEqual([]); + + const sameInstant = resolve({ + mappings: [ + rule("rm-z", "g-b", "admin", 0, at(5)), + rule("rm-a", "g-a", "member", 0, at(5)), + ], + membersByGroup: groups({ "g-a": ["u-1"], "g-b": ["u-1"] }), + candidates: [member("u-1", "member")], + }); + // Same instant: id asc decides, and "rm-a" sorts first. + expect(sameInstant).toEqual([]); + }); + + it("NEVER demotes: an admin under a winning member mapping is untouched", () => { + expect( + resolve({ + mappings: [rule("rm-1", "g-a", "member", 0)], + membersByGroup: groups({ "g-a": ["u-1"] }), + candidates: [member("u-1", "admin")], + }), + ).toEqual([]); + }); + + it("never touches an owner, under either kind of mapping", () => { + for (const role of ["admin", "member"] as const) { + expect( + resolve({ + mappings: [rule("rm-1", "g-a", role, 0)], + membersByGroup: groups({ "g-a": ["u-owner"] }), + candidates: [member("u-owner", "owner")], + }), + ).toEqual([]); + } + }); + + it("skips the acting user (mirrors 'you cannot change your own role')", () => { + expect( + resolve({ + mappings: [rule("rm-1", "g-a", "admin", 0)], + membersByGroup: groups({ "g-a": [ACTOR] }), + candidates: [member(ACTOR, "member")], + }), + ).toEqual([]); + }); + + it("skips — and warns about — a current role it does not understand", () => { + expect( + resolve({ + mappings: [rule("rm-1", "g-a", "admin", 0)], + membersByGroup: groups({ "g-a": ["u-1"] }), + candidates: [member("u-1", "superadmin")], + }), + ).toEqual([]); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it("skips — and warns about — an unrecognized role ON THE MAPPING", () => { + // Fail closed on the winner rather than falling through to the next + // mapping, which would grant more than the config says. + expect( + resolve({ + mappings: [ + rule("rm-bad", "g-a", "superadmin", 0), + rule("rm-ok", "g-b", "admin", 1), + ], + membersByGroup: groups({ "g-a": ["u-1"], "g-b": ["u-1"] }), + candidates: [member("u-1", "member")], + }), + ).toEqual([]); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it("leaves a user who is in no mapped group alone", () => { + expect( + resolve({ + mappings: [rule("rm-1", "g-a", "admin", 0)], + membersByGroup: groups({ "g-a": ["u-1"] }), + candidates: [member("u-1", "member"), member("u-2", "member")], + }).map((c) => c.userId), + ).toEqual(["u-1"]); + }); + + it("is idempotent: feeding the result back as current state changes nothing", () => { + const mappings = [ + rule("rm-1", "g-a", "admin", 0), + rule("rm-2", "g-b", "member", 1), + ]; + const membersByGroup = groups({ + "g-a": ["u-1", "u-2"], + "g-b": ["u-2", "u-3"], + }); + const candidates = [ + member("u-1", "member"), + member("u-2", "member"), + member("u-3", "member"), + ]; + + const first = resolve({ mappings, membersByGroup, candidates }); + expect(first.map((c) => c.userId).sort()).toEqual(["u-1", "u-2"]); + + const applied = candidates.map((c) => { + const change = first.find((ch) => ch.userId === c.userId); + return change ? { ...c, role: change.to } : c; + }); + expect(resolve({ mappings, membersByGroup, candidates: applied })).toEqual( + [], + ); + }); +}); diff --git a/packages/api/src/services/org-role-mapping-service.ts b/packages/api/src/services/org-role-mapping-service.ts new file mode 100644 index 00000000..044658b6 --- /dev/null +++ b/packages/api/src/services/org-role-mapping-service.ts @@ -0,0 +1,789 @@ +import { db, Prisma } from "@onecli/db"; +import { ServiceError } from "./errors"; +import { logger } from "../lib/logger"; +import { ROLE_HIERARCHY } from "../providers"; +import type { OrgRole } from "../providers"; +import { + recordAuditEvent, + AUDIT_ACTIONS, + AUDIT_SERVICES, + AUDIT_SOURCE, +} from "./audit-service"; +import { MAX_ROLE_MAPPINGS } from "../validations/org"; +import type { + CreateRoleMappingInput, + PreviewRoleMappingInput, + UpdateRoleMappingInput, +} from "../validations/org"; + +/** + * Group → org-role mappings: the mapping CONFIG (list/create/update/delete/ + * reorder/preview) plus the engine that APPLIES it to `OrganizationMember.role`. + * Scoped to ONE organization on every call — the caller's + * `auth.organizationId`, never a body parameter — so this can never read or + * write across orgs. + * + * WHEN IT APPLIES. In the cloud edition mappings are re-resolved at SSO login. + * OSS has no SSO, so the apply is driven by writes instead: (a) every + * membership write in `org-group-service.ts` calls + * `applyRoleMappingsForGroup`, and (b) every mapping-config write in + * `routes/org/role-mappings.ts` calls `applyOrgRoleMappings`. + * + * PRECEDENCE. `priority` is an ASCENDING rank — 0 wins. The FIRST mapping (by + * `priority asc, createdAt asc, id asc`) whose group contains the user + * decides that user's mapped role. Role strength plays NO part in choosing + * the winner, which is what makes ordering load-bearing: a `member` mapping at + * priority 0 SHADOWS an `admin` mapping at priority 3 for anyone in both + * groups. That is the documented way to carve an exception out of a broad + * grant. + * + * ── DECISION C: a mapping is a FLOOR, never a ceiling ──────────────────── + * + * `OrganizationMember` has NO provenance column (`organizationId, userId, + * userEmail, role, status, suspendedAt, ssoExempt, createdAt`), so the system + * cannot tell a mapping-assigned `admin` from a hand-promoted one. Deriving + * provenance from the audit log was rejected: audit writes are best-effort by + * design (`logAuditEvent` swallows its own errors), and an authorization + * decision must never hang off a log that is allowed to drop rows. + * + * The apply is therefore MONOTONIC — it may only ever RAISE a role: + * + * target(U) = strongest(current(U), mappedRole(U)) + * write iff target(U) !== current(U) // i.e. iff it is a strict raise + * + * Consequences, all deliberate: + * - a `member` mapping demotes nobody; its only effect is shadowing; + * - removing a user from an `admin`-mapped group does not demote them — the + * grant sticks; + * - a mapping is a FLOOR, so `PATCH /v1/org/members/:userId` does NOT durably + * undo one. Hand-demoting a user who is STILL in a live `admin`-mapped + * group lasts only until the next apply, which re-raises them (the apply is + * a `max`, and it has no way to know the demotion was deliberate). The + * durable remedy is to remove the user from the mapped group, or to + * delete/reorder the mapping — THEN demote. Route-pinned in + * role-mappings.test.ts ("a hand-demoted member in an admin-mapped group is + * re-raised on the next apply"); + * - the org's active-owner count is structurally unable to fall as a result + * of a mapping: owners are skipped in the resolver AND excluded by the + * `role: { not: "owner" }` predicate on both the candidate read and the + * write, so the apply never issues a statement that touches an owner row. + * + * IF YOU EVER ADD A PROVENANCE COLUMN to `OrganizationMember` (a `roleSource` + * discriminator), `resolveRoleMappingChanges` below is the function to + * revisit — it is the only place the raise-only rule is expressed. + */ + +const log = logger.child({ component: "org-role-mappings" }); + +/** One row of the mappings list (matches the client's `RoleMappingRow`). */ +export interface RoleMappingListRow { + id: string; + groupId: string; + groupName: string; + role: string; + priority: number; + memberCount: number; + createdAt: string; + updatedAt: string; +} + +/** A stored mapping, reduced to what resolution actually needs. */ +export interface MappingRule { + id: string; + groupId: string; + role: string; + priority: number; + createdAt: Date; +} + +/** A candidate member's current state. */ +export interface MemberState { + userId: string; + role: string; + userEmail: string; +} + +/** A single strict RAISE the apply will perform. */ +export interface RoleChange { + userId: string; + userEmail: string; + from: string; + to: string; + mappingId: string; + groupId: string; +} + +/** + * Which seam fired the apply — carried into the MEMBER audit rows so an + * operator can tell "someone joined a mapped group" from "someone edited the + * mapping config" from "a group delete cascaded its mapping away". + */ +export type ApplyTrigger = "membership" | "mapping" | "group-deleted"; + +const ROW_SELECT = { + id: true, + groupId: true, + role: true, + priority: true, + createdAt: true, + updatedAt: true, + group: { select: { name: true, _count: { select: { members: true } } } }, +} as const; + +const RULE_SELECT = { + id: true, + groupId: true, + role: true, + priority: true, + createdAt: true, +} as const; + +/** `priority asc, createdAt asc, id asc` — the ONE resolution order. */ +const LIST_ORDER = [ + { priority: "asc" as const }, + { createdAt: "asc" as const }, + { id: "asc" as const }, +]; + +interface MappingRowShape { + id: string; + groupId: string; + role: string; + priority: number; + createdAt: Date; + updatedAt: Date; + group: { name: string; _count: { members: number } }; +} + +const toRow = (row: MappingRowShape): RoleMappingListRow => ({ + id: row.id, + groupId: row.groupId, + groupName: row.group.name, + role: row.role, + priority: row.priority, + memberCount: row.group._count.members, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), +}); + +/** + * The list is UNPAGED, by contract: `groupId` is unique so mappings are + * bounded by group count, the client types the response as a bare array (not + * a `DirectoryPage`), and resolution needs the whole ordered set anyway. + */ +export const listOrgRoleMappings = async ( + organizationId: string, +): Promise => { + const rows = await db.groupRoleMapping.findMany({ + where: { organizationId }, + select: ROW_SELECT, + orderBy: LIST_ORDER, + }); + return rows.map(toRow); +}; + +/** + * Resolve a mapping WITHIN the caller's org — always + * `findFirst({ id, organizationId })`, NEVER `findUnique({ where: { id } })`: + * a cross-org id must read as absent (404), not leak another org's row. + */ +const requireMapping = async (organizationId: string, id: string) => { + const mapping = await db.groupRoleMapping.findFirst({ + where: { id, organizationId }, + select: { id: true, groupId: true, role: true, priority: true }, + }); + if (!mapping) throw new ServiceError("NOT_FOUND", "Role mapping not found."); + return mapping; +}; + +/** + * Resolve the mapped group, org-scoped. Deliberately NOT the groups service's + * `requireManualGroup`: a `GroupRoleMapping` is an admin-authored OneCLI + * artifact, not an IdP-owned object, so mapping a `scim` group to a role is + * the canonical use case rather than a conflict. + */ +const requireGroup = async (organizationId: string, groupId: string) => { + const group = await db.group.findFirst({ + where: { id: groupId, organizationId }, + select: { id: true, name: true }, + }); + if (!group) throw new ServiceError("NOT_FOUND", "Group not found."); + return group; +}; + +const readRow = async (organizationId: string, id: string) => { + const row = await db.groupRoleMapping.findFirst({ + where: { id, organizationId }, + select: ROW_SELECT, + }); + if (!row) throw new ServiceError("NOT_FOUND", "Role mapping not found."); + return toRow(row); +}; + +const isUniqueViolation = (err: unknown) => + err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002"; + +/** + * Per-org advisory lock, cloned in spirit from `policy-service.ts`'s + * `lockScope` (whose comment spells out why: an unlocked read-then-append can + * mint duplicate priorities under concurrency). Not imported from there — + * that helper is policy-scope-shaped. + */ +const lockOrgRoleMappings = ( + tx: Prisma.TransactionClient, + organizationId: string, +) => + tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${`role-mappings:${organizationId}`}))`; + +// ── Resolution ──────────────────────────────────────────────────────────── + +const isOrgRole = (role: string): role is OrgRole => + Object.prototype.hasOwnProperty.call(ROLE_HIERARCHY, role); + +const byPrecedence = (a: MappingRule, b: MappingRule) => + a.priority - b.priority || + a.createdAt.getTime() - b.createdAt.getTime() || + a.id.localeCompare(b.id); + +/** + * PURE. No DB, no clock. Given the org's mapping set, who is in which mapped + * group, and each candidate's current role, return the strict RAISES to + * apply. Preview and apply both call this — which is what makes the preview + * structurally incapable of lying about what the apply will do. + * + * `O(candidates × mappings)` with set lookups. Mappings are bounded by group + * count on a single-instance OSS surface, so no index gymnastics are needed. + */ +export const resolveRoleMappingChanges = (input: { + mappings: readonly MappingRule[]; + /** groupId → userIds, restricted to mapped groups. */ + membersByGroup: ReadonlyMap>; + candidates: readonly MemberState[]; + actorUserId: string; +}): RoleChange[] => { + const { mappings, membersByGroup, candidates, actorUserId } = input; + if (mappings.length === 0) return []; + + // Defensive: the loaders already order by this, but resolution must never + // depend on the caller having done so. + const ordered = [...mappings].sort(byPrecedence); + const changes: RoleChange[] = []; + + for (const candidate of candidates) { + // C2 — the acting user is skipped, mirroring `updateOrgMemberRole`'s "you + // cannot change your own role". Under raise-only this can only ever fail + // to promote the actor (fail-closed); anyone else's apply converges them. + if (candidate.userId === actorUserId) continue; + // C1 — owners are untouchable, so the org can never lose its last owner. + if (candidate.role === "owner") continue; + // C3 — never overwrite a role the system does not understand (the same + // fail-closed stance `ossRoleResolver` takes for garbage role strings). + if (!isOrgRole(candidate.role)) { + log.warn( + { userId: candidate.userId, role: candidate.role }, + "unrecognized organization member role — skipping role mapping", + ); + continue; + } + + const winner = ordered.find( + (mapping) => + membersByGroup.get(mapping.groupId)?.has(candidate.userId) ?? false, + ); + // Unmapped: the apply never touches this user. + if (!winner) continue; + if (!isOrgRole(winner.role)) { + log.warn( + { mappingId: winner.id, role: winner.role }, + "unrecognized role on a group role mapping — skipping", + ); + continue; + } + + // THE raise-only rule (decision C): write only on a strict raise. + if (ROLE_HIERARCHY[winner.role] <= ROLE_HIERARCHY[candidate.role]) continue; + + changes.push({ + userId: candidate.userId, + userEmail: candidate.userEmail, + from: candidate.role, + to: winner.role, + mappingId: winner.id, + groupId: winner.groupId, + }); + } + + return changes; +}; + +// ── Applying ────────────────────────────────────────────────────────────── + +type ApplyScope = + | { kind: "org" } + | { + kind: "group"; + groupId: string; + /** Ids the writer just REMOVED from the group (see below). */ + extraUserIds: readonly string[]; + }; + +const loadMappingRules = (organizationId: string): Promise => + db.groupRoleMapping.findMany({ + where: { organizationId }, + select: RULE_SELECT, + orderBy: LIST_ORDER, + }); + +/** + * Load the candidate members and the mapped groups' membership. + * `scopedUserIds` narrows both reads to one group's members; `undefined` + * means "every member of the org". + */ +const loadResolverState = async ( + organizationId: string, + mappings: readonly MappingRule[], + scopedUserIds: string[] | undefined, +) => { + const scoped = scopedUserIds ? { userId: { in: scopedUserIds } } : {}; + + // `role: { not: "owner" }` is decision C1 at the query layer; the resolver + // repeats the skip so it stays correct in isolation (and under unit test). + const candidates = await db.organizationMember.findMany({ + where: { organizationId, role: { not: "owner" }, ...scoped }, + select: { userId: true, role: true, userEmail: true }, + }); + + const mappedGroupIds = [...new Set(mappings.map((m) => m.groupId))]; + const memberships = await db.groupMember.findMany({ + where: { groupId: { in: mappedGroupIds }, ...scoped }, + select: { groupId: true, userId: true }, + }); + + const membersByGroup = new Map>(); + for (const row of memberships) { + const set = membersByGroup.get(row.groupId); + if (set) set.add(row.userId); + else membersByGroup.set(row.groupId, new Set([row.userId])); + } + + return { candidates, membersByGroup }; +}; + +/** + * The actor's email for the MEMBER audit rows (`AuditEventParams.userEmail` is + * required). Resolved lazily — only on the path that already writes N rows. + */ +const resolveActorEmail = async ( + organizationId: string, + actorUserId: string, +): Promise => { + const member = await db.organizationMember.findUnique({ + where: { organizationId_userId: { organizationId, userId: actorUserId } }, + select: { userEmail: true }, + }); + if (member?.userEmail) return member.userEmail; + const user = await db.user.findUnique({ + where: { id: actorUserId }, + select: { email: true }, + }); + return user?.email ?? ""; +}; + +const applyRoleMappings = async ( + organizationId: string, + actorUserId: string, + scope: ApplyScope, + trigger: ApplyTrigger, +): Promise<{ changed: number }> => { + const mappings = await loadMappingRules(organizationId); + // Short-circuit before touching membership: an org with no mappings has + // nothing to resolve. + if (mappings.length === 0) return { changed: 0 }; + + let scopedUserIds: string[] | undefined; + if (scope.kind === "group") { + // Adding to (or removing from) an UNMAPPED group cannot change anybody's + // mapped set — the winner is chosen only among mapped groups. + if (!mappings.some((m) => m.groupId === scope.groupId)) + return { changed: 0 }; + const rows = await db.groupMember.findMany({ + where: { groupId: scope.groupId }, + select: { userId: true }, + }); + // The candidate set is this group's members AFTER the write, UNIONED with + // the ids the writer just removed. The union is what makes the removal + // paths converge: a user dropped from a high-priority `member` group that + // was SHADOWING a lower-priority `admin` mapping must be re-resolved + // against the mappings that still cover them (the unshadow case decision E + // handles org-wide for `deleteOrgGroup`), and the post-write read can no + // longer see them. The membership load below is filtered on the same id + // list, so a removed user simply has no row for this group and falls + // through to the next mapping — the resolver stays exactly as correct. + scopedUserIds = [ + ...new Set([...rows.map((row) => row.userId), ...scope.extraUserIds]), + ]; + // Nothing to resolve only when the union is empty — removing the LAST + // member of a mapped group still has that member as a candidate. + if (scopedUserIds.length === 0) return { changed: 0 }; + } + + const { candidates, membersByGroup } = await loadResolverState( + organizationId, + mappings, + scopedUserIds, + ); + + const changes = resolveRoleMappingChanges({ + mappings, + membersByGroup, + candidates, + actorUserId, + }); + // Idempotence made observable: `strongest(current, mapped)` is a max over a + // total order, so a converged org produces an empty change set — and we + // return BEFORE opening any transaction (the `setOrgGroupMembers` no-delta + // precedent), writing zero rows and zero audit events. + if (changes.length === 0) return { changed: 0 }; + + const byRole = new Map(); + for (const change of changes) { + const ids = byRole.get(change.to); + if (ids) ids.push(change.userId); + else byRole.set(change.to, [change.userId]); + } + + await db.$transaction( + [...byRole].map(([role, userIds]) => + db.organizationMember.updateMany({ + // `role: { not: "owner" }` again: the last-owner invariant enforced at + // the WRITE layer, so even a resolver bug cannot lower an owner. + where: { + organizationId, + userId: { in: userIds }, + role: { not: "owner" }, + }, + data: { role }, + }), + ), + ); + + // Audits are written AFTER the transaction commits, never inside it: a + // swallowed audit error must not roll back a role change. `recordAuditEvent` + // deliberately does NOT flush the gateway cache — every caller of the apply + // is already inside a `withAudit` carrying `organizationId`, which flushes + // once at the end of the request. Do not "fix" this into N flushes. + const actorEmail = await resolveActorEmail(organizationId, actorUserId); + for (const change of changes) { + await recordAuditEvent({ + organizationId, + userId: actorUserId, + userEmail: actorEmail, + action: AUDIT_ACTIONS.UPDATE, + service: AUDIT_SERVICES.MEMBER, + source: AUDIT_SOURCE.API, + metadata: { + targetUserId: change.userId, + role: change.to, + previousRole: change.from, + // THE discriminator: what lets an operator answer "why is this person + // suddenly an admin?" from the audit log alone (the `via: + // "invitation"` precedent in audit-service.ts). + via: "role-mapping", + mappingId: change.mappingId, + groupId: change.groupId, + trigger, + }, + }); + } + + return { changed: changes.length }; +}; + +/** + * Re-resolve mapped org roles after a change to ONE group's membership. + * Called by the three membership writers in `org-group-service.ts`. + * + * `extraUserIds` are the ids the caller just REMOVED from the group: they are + * gone from the group by the time this runs, so the caller is the only thing + * that still knows they need re-resolving. Adders pass nothing. + */ +export const applyRoleMappingsForGroup = ( + organizationId: string, + groupId: string, + actorUserId: string, + extraUserIds: readonly string[] = [], +): Promise<{ changed: number }> => + applyRoleMappings( + organizationId, + actorUserId, + { kind: "group", groupId, extraUserIds }, + "membership", + ); + +/** + * Re-resolve every member's mapped role. Called by the mapping-config routes + * (a create/update/reorder/delete can change who wins for any group) and by + * `deleteOrgGroup`, whose cascade can UNSHADOW a lower-priority mapping. + */ +export const applyOrgRoleMappings = ( + organizationId: string, + actorUserId: string, + trigger: ApplyTrigger = "mapping", +): Promise<{ changed: number }> => + applyRoleMappings(organizationId, actorUserId, { kind: "org" }, trigger); + +// ── CRUD ────────────────────────────────────────────────────────────────── + +export const createOrgRoleMapping = async ( + organizationId: string, + actorUserId: string, + input: CreateRoleMappingInput, +): Promise<{ mapping: RoleMappingListRow; rolesChanged: number }> => { + await requireGroup(organizationId, input.groupId); + + // `groupId` is UNIQUE — at most one mapping per group. Friendly pre-check + // for the common case; the P2002 catch below covers the create-create race + // the pre-check cannot see (the `createOrgGroup` shape). + const dupe = await db.groupRoleMapping.findFirst({ + where: { groupId: input.groupId }, + select: { id: true }, + }); + if (dupe) { + throw new ServiceError( + "CONFLICT", + "This group already has a role mapping.", + ); + } + + let createdId: string; + try { + const created = await db.$transaction(async (tx) => { + await lockOrgRoleMappings(tx, organizationId); + // The set ceiling, checked under the same lock as the append so a race + // cannot slip past it. This is what keeps `PUT /order` reachable: the + // reorder body must be able to name every mapping at once + // (MAX_ROLE_MAPPINGS in validations/org.ts spells out the invariant). + const total = await tx.groupRoleMapping.count({ + where: { organizationId }, + }); + if (total >= MAX_ROLE_MAPPINGS) { + throw new ServiceError( + "CONFLICT", + `An organization can have at most ${MAX_ROLE_MAPPINGS} role mappings.`, + ); + } + // Append at max+1 (0 for the first mapping). Priorities are a RELATIVE + // rank and need not be dense — a cascade delete leaves holes, and + // resolution only ever compares them. + const agg = await tx.groupRoleMapping.aggregate({ + where: { organizationId }, + _max: { priority: true }, + }); + const priority = input.priority ?? (agg._max.priority ?? -1) + 1; + return tx.groupRoleMapping.create({ + data: { + organizationId, + groupId: input.groupId, + role: input.role, + priority, + }, + select: { id: true }, + }); + }); + createdId = created.id; + } catch (err) { + if (isUniqueViolation(err)) { + throw new ServiceError( + "CONFLICT", + "This group already has a role mapping.", + ); + } + throw err; + } + + // Outside the transaction: the apply writes member rows and audit rows, and + // must not hold the mapping lock while it does. + const { changed } = await applyOrgRoleMappings(organizationId, actorUserId); + return { + mapping: await readRow(organizationId, createdId), + rolesChanged: changed, + }; +}; + +export const updateOrgRoleMapping = async ( + organizationId: string, + actorUserId: string, + id: string, + input: UpdateRoleMappingInput, +): Promise<{ mapping: RoleMappingListRow; rolesChanged: number }> => { + await requireMapping(organizationId, id); + + // Org-scoped conditional write (the `renameOrgGroup` pattern): a count of 0 + // means the row vanished between the check and the write — a 404, not the + // P2025 500 a bare `update()` would surface. + const { count } = await db.groupRoleMapping.updateMany({ + where: { id, organizationId }, + data: { + role: input.role, + ...(input.priority !== undefined ? { priority: input.priority } : {}), + }, + }); + if (count === 0) + throw new ServiceError("NOT_FOUND", "Role mapping not found."); + + const { changed } = await applyOrgRoleMappings(organizationId, actorUserId); + return { mapping: await readRow(organizationId, id), rolesChanged: changed }; +}; + +export const deleteOrgRoleMapping = async ( + organizationId: string, + actorUserId: string, + id: string, +): Promise<{ + id: string; + groupId: string; + role: string; + rolesChanged: number; +}> => { + const mapping = await requireMapping(organizationId, id); + + const { count } = await db.groupRoleMapping.deleteMany({ + where: { id, organizationId }, + }); + if (count === 0) + throw new ServiceError("NOT_FOUND", "Role mapping not found."); + + // The UNSHADOW case: removing a high-priority `member` mapping can promote + // everyone it was suppressing, so the delete re-resolves the whole org. + const { changed } = await applyOrgRoleMappings(organizationId, actorUserId); + return { + id: mapping.id, + groupId: mapping.groupId, + role: mapping.role, + rolesChanged: changed, + }; +}; + +export const reorderOrgRoleMappings = async ( + organizationId: string, + actorUserId: string, + orderedIds: string[], +): Promise<{ mappings: RoleMappingListRow[]; rolesChanged: number }> => { + try { + await db.$transaction(async (tx) => { + // Validate + write under the same per-org lock `create` takes, so a + // reorder cannot interleave with a concurrent append. + await lockOrgRoleMappings(tx, organizationId); + const current = await tx.groupRoleMapping.findMany({ + where: { organizationId }, + select: { id: true, priority: true }, + orderBy: LIST_ORDER, + }); + const ids = new Set(current.map((row) => row.id)); + const namesEveryMappingOnce = + orderedIds.length === ids.size && + new Set(orderedIds).size === orderedIds.length && + orderedIds.every((id) => ids.has(id)); + if (!namesEveryMappingOnce) { + throw new ServiceError( + "CONFLICT", + "Role mapping set changed — refresh and try again.", + ); + } + + // No-delta early return (the `setOrgGroupMembers` convention): the + // stored order already IS the requested one and priorities are already + // dense `0..n-1`, so there is nothing to write. + const settled = current.every( + (row, i) => row.id === orderedIds[i] && row.priority === i, + ); + if (settled) return; + + // Ascending, 0-based: index 0 → priority 0 (highest precedence). + for (const [i, id] of orderedIds.entries()) { + await tx.groupRoleMapping.update({ + where: { id }, + data: { priority: i }, + }); + } + }); + } catch (err) { + // A delete committed between the in-tx read and an update (deletes don't + // take the lock) surfaces as P2025 — same staleness, same 409. + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === "P2025" + ) { + throw new ServiceError( + "CONFLICT", + "Role mapping set changed — refresh and try again.", + ); + } + throw err; + } + + // Runs even on the settled path: it is the only thing that converges an org + // whose mapping set was edited out of band, and it is a no-op when nothing + // changed. + const { changed } = await applyOrgRoleMappings(organizationId, actorUserId); + return { + mappings: await listOrgRoleMappings(organizationId), + rolesChanged: changed, + }; +}; + +/** + * Dry run for the create/edit dialog: how many members the proposed mapping + * would RAISE. Zero writes, zero audit rows — it is a POST only because the + * client made it one. + * + * Because the count only ever counts raises, a `member` mapping (and an + * `admin → member` edit) previews as `0`. That is truthful: such a mapping's + * effect is shadowing, not demotion. + */ +export const previewOrgRoleMapping = async ( + organizationId: string, + actorUserId: string, + input: PreviewRoleMappingInput, +): Promise<{ affectedCount: number }> => { + // Org-scoped: a preview against a foreign group must not leak its existence. + await requireGroup(organizationId, input.groupId); + + const stored = await loadMappingRules(organizationId); + const existing = stored.find((m) => m.groupId === input.groupId); + const proposed: MappingRule[] = existing + ? // An existing mapping keeps its SLOT — only the role is proposed, so a + // shadowed group correctly previews as 0. + stored.map((m) => + m.groupId === input.groupId ? { ...m, role: input.role } : m, + ) + : [ + ...stored, + { + id: "preview", + groupId: input.groupId, + role: input.role, + // Mirrors create's default, so the preview matches what the create + // button will actually do. + priority: + stored.reduce((max, m) => Math.max(max, m.priority), -1) + 1, + createdAt: new Date(), + }, + ]; + + const { candidates, membersByGroup } = await loadResolverState( + organizationId, + proposed, + undefined, + ); + + // Same resolver, same actor: the preview inherits the owner skip, the self + // skip and the raise-only rule, and therefore cannot over-promise. + const changes = resolveRoleMappingChanges({ + mappings: proposed, + membersByGroup, + candidates, + actorUserId, + }); + return { affectedCount: changes.length }; +}; diff --git a/packages/api/src/services/organization-service.test.ts b/packages/api/src/services/organization-service.test.ts index e93a98b4..5d3e73b7 100644 --- a/packages/api/src/services/organization-service.test.ts +++ b/packages/api/src/services/organization-service.test.ts @@ -14,6 +14,8 @@ interface MemberRow { userId: string; userEmail: string; role: string; + /** Absent on the provisioning writes; Prisma defaults it to "active". */ + status?: string; } interface ProjectRow { id: string; @@ -24,6 +26,17 @@ interface ProjectRow { createdByUserEmail: string | null; seq: number; } +interface BindingRow { + projectId: string; + /** Exactly one of userId/groupId, as the DB CHECK requires. */ + userId?: string; + groupId?: string; + role: string; +} +interface GroupMemberRow { + groupId: string; + userId: string; +} interface ApiKeyRow { key: string; userId: string; @@ -36,99 +49,208 @@ const store = vi.hoisted(() => ({ orgs: [] as OrgRow[], members: [] as MemberRow[], projects: [] as ProjectRow[], + bindings: [] as BindingRow[], + groupMembers: [] as GroupMemberRow[], apiKeys: [] as ApiKeyRow[], seq: 0, })); -vi.mock("@onecli/db", () => ({ - db: { - organization: { - findUnique: async ({ where: { slug } }: { where: { slug: string } }) => - store.orgs.find((o) => o.slug === slug) ?? null, - findUniqueOrThrow: async ({ - where: { slug }, - }: { - where: { slug: string }; - }) => { - const org = store.orgs.find((o) => o.slug === slug); - if (!org) throw new Error(`org ${slug} not found`); - return org; - }, - create: async ({ data }: { data: OrgRow }) => { - if (store.orgs.some((o) => o.slug === data.slug)) { - throw new Error("unique constraint: organization.slug"); - } - const org: OrgRow = { id: data.id, slug: data.slug, name: data.name }; - store.orgs.push(org); - return org; - }, - }, - organizationMember: { - upsert: async ({ - where: { organizationId_userId }, - create, - }: { - where: { - organizationId_userId: { organizationId: string; userId: string }; - }; - create: MemberRow; - }) => { - const existing = store.members.find( - (m) => - m.organizationId === organizationId_userId.organizationId && - m.userId === organizationId_userId.userId, +vi.mock("@onecli/db", () => { + /** The subset of the Prisma project `where` shapes these helpers build. */ + interface BindingClause { + userId?: string; + group?: { members: { some: { userId: string } } }; + } + interface ProjectWhere { + id?: { not: string }; + organizationId?: string; + createdByUserId?: string; + organization?: { + members: { some: { userId: string; status?: { not?: string } } }; + }; + accessBindings?: { some: { OR: BindingClause[] } }; + OR?: ProjectWhere[]; + } + + /** A binding on `projectId` satisfying any clause — direct or via a group. */ + const matchesBinding = (projectId: string, clauses: BindingClause[]) => + clauses.some((clause) => { + if (clause.userId !== undefined) { + return store.bindings.some( + (b) => b.projectId === projectId && b.userId === clause.userId, ); - if (existing) return existing; - store.members.push(create); - return create; + } + const userId = clause.group?.members.some.userId; + if (userId === undefined) return false; + return store.bindings.some( + (b) => + b.projectId === projectId && + b.groupId !== undefined && + store.groupMembers.some( + (gm) => gm.groupId === b.groupId && gm.userId === userId, + ), + ); + }); + + const matchesProject = (p: ProjectRow, where: ProjectWhere): boolean => { + if (where.id?.not !== undefined && p.id === where.id.not) return false; + if ( + where.organizationId !== undefined && + p.organizationId !== where.organizationId + ) + return false; + if ( + where.createdByUserId !== undefined && + p.createdByUserId !== where.createdByUserId + ) + return false; + if (where.organization) { + const { userId, status } = where.organization.members.some; + const membership = store.members.find( + (m) => m.organizationId === p.organizationId && m.userId === userId, + ); + if (!membership) return false; + if ( + status?.not !== undefined && + (membership.status ?? "active") === status.not + ) + return false; + } + if ( + where.accessBindings && + !matchesBinding(p.id, where.accessBindings.some.OR) + ) + return false; + if (where.OR && !where.OR.some((sub) => matchesProject(p, sub))) + return false; + return true; + }; + + return { + db: { + organization: { + findUnique: async ({ where: { slug } }: { where: { slug: string } }) => + store.orgs.find((o) => o.slug === slug) ?? null, + findUniqueOrThrow: async ({ + where: { slug }, + }: { + where: { slug: string }; + }) => { + const org = store.orgs.find((o) => o.slug === slug); + if (!org) throw new Error(`org ${slug} not found`); + return org; + }, + create: async ({ data }: { data: OrgRow }) => { + if (store.orgs.some((o) => o.slug === data.slug)) { + throw new Error("unique constraint: organization.slug"); + } + const org: OrgRow = { id: data.id, slug: data.slug, name: data.name }; + store.orgs.push(org); + return org; + }, }, - }, - project: { - findFirst: async ({ - where: { organizationId, createdByUserId }, - }: { - where: { organizationId: string; createdByUserId: string }; - }) => - store.projects - .filter( - (p) => - p.organizationId === organizationId && - p.createdByUserId === createdByUserId, - ) - .sort((a, b) => a.seq - b.seq)[0] ?? null, - create: async ({ data }: { data: Omit }) => { - if ( - store.projects.some( - (p) => - p.organizationId === data.organizationId && p.slug === data.slug, - ) - ) { - throw new Error("unique constraint: (organizationId, slug)"); - } - const project: ProjectRow = { ...data, seq: store.seq++ }; - store.projects.push(project); - return project; + organizationMember: { + upsert: async ({ + where: { organizationId_userId }, + create, + }: { + where: { + organizationId_userId: { organizationId: string; userId: string }; + }; + create: MemberRow; + }) => { + const existing = store.members.find( + (m) => + m.organizationId === organizationId_userId.organizationId && + m.userId === organizationId_userId.userId, + ); + if (existing) return existing; + store.members.push(create); + return create; + }, + findFirst: async ({ + where, + }: { + where: { userId: string; status?: { not?: string } }; + }) => + store.members.find( + (m) => + m.userId === where.userId && + !( + where.status?.not !== undefined && + (m.status ?? "active") === where.status.not + ), + ) ?? null, }, - }, - apiKey: { - findFirst: async ({ - where: { organizationId, scope }, - }: { - where: { organizationId: string; scope: string }; - }) => - store.apiKeys.find( - (k) => k.organizationId === organizationId && k.scope === scope, - ) ?? null, - create: async ({ data }: { data: ApiKeyRow }) => { - if (store.apiKeys.some((k) => k.key === data.key)) { - throw new Error("unique constraint: api_key.key"); - } - store.apiKeys.push(data); - return data; + project: { + findFirst: async ({ + where, + select, + }: { + where: ProjectWhere; + select?: { id?: boolean; organizationId?: boolean }; + }) => { + const row = + store.projects + .filter((p) => matchesProject(p, where)) + .sort((a, b) => a.seq - b.seq)[0] ?? null; + // Honour Prisma's `select` so callers see the same narrow shape they + // asked for (these helpers only ever select id + organizationId). + if (!row || !select) return row; + const picked: Record = {}; + if (select.id) picked.id = row.id; + if (select.organizationId) picked.organizationId = row.organizationId; + return picked; + }, + create: async ({ + data, + }: { + data: Omit & { + accessBindings?: { create: { userId: string; role: string } }; + }; + }) => { + if ( + store.projects.some( + (p) => + p.organizationId === data.organizationId && + p.slug === data.slug, + ) + ) { + throw new Error("unique constraint: (organizationId, slug)"); + } + const project: ProjectRow = { ...data, seq: store.seq++ }; + store.projects.push(project); + // Materialize the nested binding write so the binding-fallback arm of + // findUserDefaultProject has something real to find. + if (data.accessBindings) { + store.bindings.push({ + projectId: data.id, + ...data.accessBindings.create, + }); + } + return project; + }, + }, + apiKey: { + findFirst: async ({ + where: { organizationId, scope }, + }: { + where: { organizationId: string; scope: string }; + }) => + store.apiKeys.find( + (k) => k.organizationId === organizationId && k.scope === scope, + ) ?? null, + create: async ({ data }: { data: ApiKeyRow }) => { + if (store.apiKeys.some((k) => k.key === data.key)) { + throw new Error("unique constraint: api_key.key"); + } + store.apiKeys.push(data); + return data; + }, }, }, - }, -})); + }; +}); vi.mock("../lib/logger", () => ({ logger: { warn: () => {}, info: () => {}, error: () => {} }, @@ -136,6 +258,7 @@ vi.mock("../lib/logger", () => ({ import { joinSharedOrganization, + hasResolvableProjectExcluding, SHARED_ORG_SLUG, } from "./organization-service"; @@ -143,12 +266,28 @@ beforeEach(() => { store.orgs = []; store.members = []; store.projects = []; + store.bindings = []; + store.groupMembers = []; store.apiKeys = []; store.seq = 0; delete process.env.ONECLI_ORG_API_KEY; delete process.env.ONECLI_ORG_API_KEY_FILE; }); +const ORG = "org-host"; +const HOST = "user-host"; +const GUEST = "user-guest"; + +const seedOrgWithMember = (userId: string, role = "member") => { + store.orgs.push({ id: ORG, slug: "host", name: "Host" }); + store.members.push({ + organizationId: ORG, + userId, + userEmail: `${userId}@example.com`, + role, + }); +}; + describe("joinSharedOrganization", () => { it("creates the one shared org and a project for the first user", async () => { const { organization, project } = await joinSharedOrganization( @@ -257,3 +396,119 @@ describe("bootstrap org API key (via joinSharedOrganization)", () => { ).rejects.toThrow(/ONECLI_ORG_API_KEY/); }); }); + +// `hasResolvableProjectExcluding` is `deleteProject`'s lockout oracle, and it +// must answer exactly what `findUserDefaultProject` would find once the named +// project is gone. Every arm below has a twin above; drift between the two is a +// lockout (the user resolves no project and gets a 401 on every request). + +describe("hasResolvableProjectExcluding", () => { + const seedProject = ( + id: string, + createdByUserId: string | null, + organizationId = ORG, + ) => { + store.projects.push({ + id, + name: "Default", + slug: id, + organizationId, + createdByUserId, + createdByUserEmail: null, + seq: store.seq++, + }); + }; + + it("is false when the excluded project is the user's only one", async () => { + seedOrgWithMember(GUEST); + seedProject("proj-only", GUEST); + store.bindings.push({ + projectId: "proj-only", + userId: GUEST, + role: "owner", + }); + + await expect( + hasResolvableProjectExcluding(GUEST, "proj-only"), + ).resolves.toBe(false); + }); + + it("is true through a project they CREATED (arm 1)", async () => { + seedOrgWithMember(GUEST); + seedProject("proj-a", GUEST); + seedProject("proj-b", GUEST); + + await expect(hasResolvableProjectExcluding(GUEST, "proj-a")).resolves.toBe( + true, + ); + }); + + it("is true through a DIRECT binding on another project (arm 2)", async () => { + seedOrgWithMember(HOST, "owner"); + store.members.push({ + organizationId: ORG, + userId: GUEST, + userEmail: "guest@example.com", + role: "member", + }); + seedProject("proj-a", HOST); + seedProject("proj-b", HOST); + store.bindings.push({ projectId: "proj-a", userId: GUEST, role: "member" }); + store.bindings.push({ projectId: "proj-b", userId: GUEST, role: "member" }); + + await expect(hasResolvableProjectExcluding(GUEST, "proj-a")).resolves.toBe( + true, + ); + }); + + it("is true through a GROUP binding on another project (arm 2)", async () => { + seedOrgWithMember(HOST, "owner"); + store.members.push({ + organizationId: ORG, + userId: GUEST, + userEmail: "guest@example.com", + role: "member", + }); + seedProject("proj-a", HOST); + seedProject("proj-b", HOST); + store.bindings.push({ projectId: "proj-a", userId: GUEST, role: "member" }); + store.bindings.push({ + projectId: "proj-b", + groupId: "g-1", + role: "member", + }); + store.groupMembers.push({ groupId: "g-1", userId: GUEST }); + + await expect(hasResolvableProjectExcluding(GUEST, "proj-a")).resolves.toBe( + true, + ); + // ...and the group path is the ONLY one left, so removing it flips it. + store.groupMembers = []; + await expect(hasResolvableProjectExcluding(GUEST, "proj-a")).resolves.toBe( + false, + ); + }); + + it("ignores a project in an org the user is only SUSPENDED in", async () => { + seedOrgWithMember(GUEST); + const suspended = store.members.find((m) => m.userId === GUEST); + if (suspended) suspended.status = "suspended"; + seedProject("proj-a", GUEST); + seedProject("proj-b", GUEST); + + await expect(hasResolvableProjectExcluding(GUEST, "proj-a")).resolves.toBe( + false, + ); + }); + + it("ignores projects of an org the user does not belong to", async () => { + seedOrgWithMember(GUEST); + seedProject("proj-a", GUEST); + store.orgs.push({ id: "org-other", slug: "other", name: "Other" }); + seedProject("proj-foreign", GUEST, "org-other"); + + await expect(hasResolvableProjectExcluding(GUEST, "proj-a")).resolves.toBe( + false, + ); + }); +}); diff --git a/packages/api/src/services/organization-service.ts b/packages/api/src/services/organization-service.ts index ce43d0df..5df079c8 100644 --- a/packages/api/src/services/organization-service.ts +++ b/packages/api/src/services/organization-service.ts @@ -86,6 +86,50 @@ export const findUserDefaultProject = async ( }); }; +/** + * Whether `userId` would still resolve SOME project if `excludeProjectId` + * disappeared — the delete guard's lockout oracle + * (`deleteProject`, project-service). + * + * THESE TWO MUST AGREE: this is exactly `findUserDefaultProject`'s disjunction + * (created-by-them, OR bound directly / through a group), fenced to orgs the + * user is an ACTIVE member of, minus the project about to be deleted. It lives + * here rather than in project-service precisely so the two predicates stay in + * sync — drift between them is a lockout: a user whose last project is deleted + * resolves no project at all, and session auth then 401s them everywhere. + * + * Both arms are folded into one `OR` (unlike the ordered two-query fallback + * above) because only existence matters here, never which project wins. + */ +export const hasResolvableProjectExcluding = async ( + userId: string, + excludeProjectId: string, +): Promise => { + const inActiveMemberOrg = { + organization: { members: { some: { userId, ...activeMembershipWhere } } }, + }; + + const row = await db.project.findFirst({ + where: { + ...inActiveMemberOrg, + id: { not: excludeProjectId }, + OR: [ + { createdByUserId: userId }, + { + accessBindings: { + some: { + OR: [{ userId }, { group: { members: { some: { userId } } } }], + }, + }, + }, + ], + }, + select: { id: true }, + }); + + return row !== null; +}; + /** * The nested-write seeds every user-facing project is born with: one API * key + the default agent. The single definition all provision sites diff --git a/packages/api/src/services/policy-oss-locks.test.ts b/packages/api/src/services/policy-oss-locks.test.ts deleted file mode 100644 index 71df400c..00000000 --- a/packages/api/src/services/policy-oss-locks.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { ossPolicyValidator } from "./policy-oss-locks"; -import { ServiceError } from "./errors"; -import type { PolicyTargetInput } from "../validations/policy"; - -// The OSS edition's policy locks. These run against the DEFAULT registries — -// exactly what an OSS process sees (no initEeApps): base apps available, the -// shared EE-stub list (aws-role, datadog, …) present with `available: false`. - -describe("ossPolicyValidator.validate (granular session policy)", () => { - it("rejects unconditionally with the cloud-only message", async () => { - await expect( - ossPolicyValidator.validate("org-1", "github", null, { - repositories: ["a/b"], - }), - ).rejects.toMatchObject({ - code: "UNPROCESSABLE", - message: - "Granular resource scoping (repositories/folders) is available on OneCLI Cloud.", - }); - }); -}); - -describe("ossPolicyValidator.validateTargets (cloud-only apps)", () => { - const run = (targets: PolicyTargetInput[]) => - ossPolicyValidator.validateTargets!(targets); - - it("rejects an app target for a cloud-only (EE-stub) provider, naming the app", async () => { - const err = await run([{ kind: "app", provider: "aws-role" }]).catch( - (e: unknown) => e, - ); - expect(err).toBeInstanceOf(ServiceError); - expect((err as ServiceError).code).toBe("UNPROCESSABLE"); - expect((err as ServiceError).message).toBe( - "AWS Role connections are available on OneCLI Cloud.", - ); - }); - - it("rejects when the cloud-only target is mixed among valid ones", async () => { - await expect( - run([ - { kind: "network", hostPattern: "api.example.com" }, - { kind: "app", provider: "datadog" }, - ]), - ).rejects.toMatchObject({ code: "UNPROCESSABLE" }); - }); - - it("accepts a base (connectable) app", async () => { - await expect( - run([{ kind: "app", provider: "github" }]), - ).resolves.toBeUndefined(); - }); - - it("accepts an UNKNOWN provider string (typos, and onprem-style excluded apps, stay non-fatal)", async () => { - await expect( - run([{ kind: "app", provider: "not-a-real-app" }]), - ).resolves.toBeUndefined(); - }); - - it("ignores non-app target kinds", async () => { - await expect( - run([ - { kind: "network", hostPattern: "*.x.com" }, - { kind: "secret", secretScope: "project" }, - { kind: "connection", connectionId: "conn-1" }, - ]), - ).resolves.toBeUndefined(); - }); - - it("accepts an empty target list", async () => { - await expect(run([])).resolves.toBeUndefined(); - }); -}); diff --git a/packages/api/src/services/policy-oss-locks.ts b/packages/api/src/services/policy-oss-locks.ts deleted file mode 100644 index c2fcceaf..00000000 --- a/packages/api/src/services/policy-oss-locks.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * The OSS edition's policy locks (step 9.5): shared implementations wired ONLY - * through the OSS init seam (`apps/web/src/lib/init/api.ts`, aliased away by - * every EE edition). The provider-hook DEFAULTS stay permissive — cloud's - * in-process web app relies on them before its init warms — so the locks are - * wired, not defaulted. - */ -import { ServiceError } from "./errors"; -import type { PolicyValidator } from "../providers"; -import { getApp } from "../apps/registry"; - -/** - * OSS rejects granular resource scoping outright. One seam covers both storage - * paths: `assertSessionPolicyValid` (policy-rule create/update/publish) and - * the legacy equipment `sessionPolicy` write both call - * `getPolicyValidator().validate(...)`. Without this lock OSS would - * accept-and-store `{repositories}`/`{folders}` that its gateway never - * enforces — false security, worse than absence. - * - * `validateTargets` (create/update only) rejects app targets naming a - * cloud-only provider — the registry's EE stubs (`available: false`), which - * the OSS gateway's base catalog can't resolve, so the rule would be dead. - * The editor locks the same key visually; this is the belt for the CLI/API - * path. App targets only: `assertTargetsValid` proves a connection target's - * OWNERSHIP, not connectability — but no OSS flow can mint an EE-provider - * connection in the first place (connect rejects `cloud_only` providers), so - * connection targets need no provider check. Unknown provider strings stay - * accepted (today's behavior). - */ -export const ossPolicyValidator: PolicyValidator = { - validate: async () => { - throw new ServiceError( - "UNPROCESSABLE", - "Granular resource scoping (repositories/folders) is available on OneCLI Cloud.", - ); - }, - validateTargets: async (targets) => { - for (const t of targets) { - if (t.kind !== "app") continue; - const app = getApp(t.provider); - if (app?.available === false) { - throw new ServiceError( - "UNPROCESSABLE", - `${app.name} connections are available on OneCLI Cloud.`, - ); - } - } - }, -}; diff --git a/packages/api/src/services/policy-service.ts b/packages/api/src/services/policy-service.ts index 5c1b2a07..a36c5aa8 100644 --- a/packages/api/src/services/policy-service.ts +++ b/packages/api/src/services/policy-service.ts @@ -1,6 +1,5 @@ import { db, Prisma } from "@onecli/db"; import { ServiceError } from "./errors"; -import { isOssEdition } from "../lib/policy-flags"; import { type ResourceScope } from "./resource-scope"; import { getPolicyValidator, getRuleActionGate } from "../providers"; import type { @@ -363,15 +362,12 @@ export const assertIdentitiesValid = async ( const userIds = idsOf("user"); const groupIds = idsOf("group"); - // Level restriction. The OSS edition phrases it as the capability lock it - // is there (directory identities are a OneCLI Cloud capability); the EE - // editions keep the scope-shaped message byte-identical. + // Level restriction — the same scope-shaped rule in every edition: a project + // rule targets agents, an org rule targets directory identities. if (base.scope === "project" && (userIds.length || groupIds.length)) { throw new ServiceError( "UNPROCESSABLE", - isOssEdition() - ? "Group and user identities are available on OneCLI Cloud." - : "A project rule can target a specific agent or all agents.", + "A project rule can target a specific agent or all agents.", ); } if (base.scope === "organization" && agentIds.length) { @@ -561,8 +557,8 @@ export const assertTargetsValid = async ( * with a connection target — then runs the wired policy validator per * connection target. EE deep-checks the shape against the provider (repos * exist on the installation, absolute Dropbox paths) and gates the team+ - * entitlement; OSS wires a validator that REJECTS session policies outright - * (granular scoping is a OneCLI Cloud capability — step 9.5). A no-op for + * entitlement; OSS wires no validator — the permissive default accepts + * session policies the OSS gateway does not yet enforce (Tier 3). A no-op for * behavioral / absent conditions. Same org fence as `assertTargetsValid`. * * Callers pass the MERGED (post-update) action/targets/conditions, so no PATCH diff --git a/packages/api/src/services/project-access-service.ts b/packages/api/src/services/project-access-service.ts new file mode 100644 index 00000000..d96d2a74 --- /dev/null +++ b/packages/api/src/services/project-access-service.ts @@ -0,0 +1,385 @@ +import { db } from "@onecli/db"; +import { ServiceError } from "./errors"; +import { requireProject } from "./project-service"; +import { hasResolvableProjectExcluding } from "./organization-service"; +import { + MAX_PROJECT_ACCESS_GROUPS, + MAX_PROJECT_ACCESS_USERS, + type SetProjectAccessInput, +} from "../validations/project"; + +// The project's human sharing surface: read the bindings, replace the set. +// +// `ProjectAccess` rows are LIVE authorization data read by three independent +// enforcement points — `middleware/auth/resolve.ts` (`hasProjectBinding`), the +// API-key auth path, and the Rust gateway's `load_principal_set`. None of them +// reads `role`: it is a MANAGEMENT discriminator only (13c), consulted solely +// by `canManageProject`. Every row is a use grant regardless of its role. +// +// Same three rules as `project-service.ts`: org-scoped `findFirst` (never +// `findUnique({ id })`), the org id always from `auth.organizationId`, and +// conditional writes. + +/** One user binding, in the client's `ProjectAccessUserRow` shape. */ +export interface ProjectAccessUserBinding { + id: string; + userId: string; + name: string | null; + email: string; + role: "owner" | "member"; + isOwner: boolean; + createdAt: string; +} + +/** One group binding, in the client's `ProjectAccessGroupRow` shape. */ +export interface ProjectAccessGroupBinding { + id: string; + groupId: string; + name: string; + memberCount: number; + createdAt: string; +} + +export interface ProjectAccessBindings { + users: ProjectAccessUserBinding[]; + groups: ProjectAccessGroupBinding[]; +} + +/** + * The delta a replace-set applied. Counts are AGGREGATED across users AND + * groups — the dialog shows a single toast, so a split would be noise. + */ +export interface SetProjectAccessResult { + added: number; + removed: number; + roleChanged: number; +} + +/** `role` is a free-form DB column: normalize, NEVER cast (the ossRoleResolver + * precedent). Anything that is not exactly "owner" is a plain use grant. */ +const normalizeRole = (raw: string): "owner" | "member" => + raw === "owner" ? "owner" : "member"; + +/** + * Read the project's bindings. + * + * User rows are filtered down to users who hold an `OrganizationMember` row in + * this org (ANY status). A row for a non-member is inert anyway + * (`canAccessProjectAsUser` demands an active membership) — and returning it + * would make the UI's "open dialog, save without edits" round-trip 400 on + * `setProjectAccess`'s org-membership assertion. SUSPENDED members ARE + * returned: suspension is an auth-time gate, not a binding change. + * + * If this filter changes, `setProjectAccess`'s validation must change with it. + */ +export const listProjectAccess = async ( + organizationId: string, + projectId: string, +): Promise => { + const project = await requireProject(organizationId, projectId); + + const [userRows, groupRows] = await Promise.all([ + db.projectAccess.findMany({ + where: { + projectId, + userId: { not: null }, + user: { organizationMemberships: { some: { organizationId } } }, + }, + select: { + id: true, + userId: true, + role: true, + createdAt: true, + user: { select: { email: true, name: true } }, + }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + // Bounded even against a hand-seeded database. + take: MAX_PROJECT_ACCESS_USERS, + }), + db.projectAccess.findMany({ + where: { + projectId, + groupId: { not: null }, + // Org-fences the join exactly like the gateway's + // `JOIN groups g ON … g.organization_id = $org`. + group: { organizationId }, + }, + select: { + id: true, + groupId: true, + createdAt: true, + group: { + select: { name: true, _count: { select: { members: true } } }, + }, + }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + take: MAX_PROJECT_ACCESS_GROUPS, + }), + ]); + + return { + users: userRows.flatMap((row) => + row.userId + ? [ + { + id: row.id, + userId: row.userId, + name: row.user?.name ?? null, + email: row.user?.email ?? "", + role: normalizeRole(row.role), + // Provenance ONLY, deliberately independent of the (transferable) + // management role: the creator keeps the badge after a demotion. + isOwner: row.userId === project.createdByUserId, + createdAt: row.createdAt.toISOString(), + }, + ] + : [], + ), + groups: groupRows.flatMap((row) => + row.groupId + ? [ + { + id: row.id, + groupId: row.groupId, + name: row.group?.name ?? "", + memberCount: row.group?._count.members ?? 0, + createdAt: row.createdAt.toISOString(), + }, + ] + : [], + ), + }; +}; + +/** + * THE security invariant of every binding write: `ProjectAccess.userId` FKs the + * GLOBAL `User` table, so the organization scope exists ONLY in this check. + * Every id must resolve to a member of the caller's org — one foreign id and + * the whole write is rejected, or a project could capture users from another + * organization. (A clone of `assertOrgMembers` in `org-group-service.ts`: the + * org services keep their own copies by convention, which keeps this comment + * next to the code it protects.) + * + * Suspended members are deliberately allowed: suspension is an AUTH-time gate, + * and stripping bindings on suspend would silently rewrite the member's access + * shape on reinstate. + */ +const assertOrgMembers = async (organizationId: string, userIds: string[]) => { + if (userIds.length === 0) return; + const rows = await db.organizationMember.findMany({ + where: { organizationId, userId: { in: userIds } }, + select: { userId: true }, + }); + const known = new Set(rows.map((row) => row.userId)); + if (userIds.some((id) => !known.has(id))) { + throw new ServiceError( + "BAD_REQUEST", + "One or more users are not members of this organization.", + ); + } +}; + +/** The same fence for group grantees. Unlike group MEMBERSHIP (IdP-owned, so + * `requireManualGroup` applies), a project grant TO a group is OneCLI-owned + * config: `source: "scim"` groups are valid grantees. */ +const assertOrgGroups = async (organizationId: string, groupIds: string[]) => { + if (groupIds.length === 0) return; + const rows = await db.group.findMany({ + where: { organizationId, id: { in: groupIds } }, + select: { id: true }, + }); + const known = new Set(rows.map((row) => row.id)); + if (groupIds.some((id) => !known.has(id))) { + throw new ServiceError( + "BAD_REQUEST", + "One or more groups do not belong to this organization.", + ); + } +}; + +/** + * Replace the project's binding set (users + groups) in one write. + * + * `actorIsOrgAdmin` is computed by the ROUTE from the role resolver (it already + * resolved the role for `canManageProject`) and passed in: this service must + * never re-resolve it, and must never accept it from the request body. + */ +export const setProjectAccess = async ( + organizationId: string, + actorUserId: string, + actorIsOrgAdmin: boolean, + projectId: string, + input: SetProjectAccessInput, +): Promise => { + // Resolve FIRST, before any payload validation: a cross-org project id must + // 404 without ever becoming an existence oracle for user/group ids. + const project = await requireProject(organizationId, projectId); + + const users = input.users; + // Groups carry no role, so a repeat is unambiguous — dedupe silently + // (matching `setOrgGroupMembers`). Duplicate USERS are a 422 in the schema. + const groupIds = [...new Set(input.groupIds)]; + + await assertOrgMembers( + organizationId, + users.map((u) => u.userId), + ); + await assertOrgGroups(organizationId, groupIds); + + // ── Guard G: the set must keep an owner ────────────────────────────────── + // Covers three failure modes at once: demoting every owner, clearing all + // users, and the legacy zero-binding project (the admin is forced to name an + // owner rather than saving an empty set over an already-orphaned project). + if (!users.some((u) => u.role === "owner")) { + throw new ServiceError( + "BAD_REQUEST", + "A project must keep at least one owner.", + ); + } + + const currentRows = await db.projectAccess.findMany({ + where: { projectId }, + select: { id: true, userId: true, groupId: true, role: true }, + }); + + const currentUsers = new Map(); + const currentGroups = new Set(); + for (const row of currentRows) { + if (row.userId) currentUsers.set(row.userId, normalizeRole(row.role)); + else if (row.groupId) currentGroups.add(row.groupId); + } + + // ── Guard H: the actor may not strand themselves ───────────────────────── + // A NON-ADMIN may neither drop nor demote themselves: the binding IS their + // authority here. (Mirrors `updateOrgMemberStatus`'s "You cannot suspend + // yourself.") + // + // An org admin keeps the DEMOTION exemption — their authority comes from the + // org role, so an ownership hand-off must stay possible — but NOT a free + // self-REMOVAL. `findUserDefaultProject` has exactly two arms (created the + // project, or holds a binding), so an admin whose only path to any project + // was this binding resolves NO project once it is gone: `authenticateSession` + // then falls back to an `X-Organization-Id` header OSS web never sends and + // 401s every request — including the PUT they would need to re-grant. That is + // the same lockout `deleteProject` spends three guards preventing, so it is + // checked with the same oracle. Excluding THIS project is correct: the + // binding on it is exactly what is going away, while the created-by arm + // survives the write and is checked separately. + // + // Deliberately conservative: a group binding on THIS project that would keep + // the admin resolving is not counted, so the worst case is a refusal to + // perform a safe removal — never a lockout. + if (currentUsers.has(actorUserId)) { + const mine = users.find((u) => u.userId === actorUserId); + if (!mine) { + if (!actorIsOrgAdmin) { + throw new ServiceError( + "BAD_REQUEST", + "You cannot remove your own access to this project.", + ); + } + const staysResolvable = + project.createdByUserId === actorUserId || + (await hasResolvableProjectExcluding(actorUserId, projectId)); + if (!staysResolvable) { + throw new ServiceError( + "BAD_REQUEST", + "Removing your own access would leave you with no project.", + ); + } + } else if ( + !actorIsOrgAdmin && + mine.role !== "owner" && + currentUsers.get(actorUserId) === "owner" + ) { + throw new ServiceError( + "BAD_REQUEST", + "You cannot remove your own management access to this project.", + ); + } + } + + const targetUsers = new Map(users.map((u) => [u.userId, u.role])); + const targetGroups = new Set(groupIds); + + const userAdds = users.filter((u) => !currentUsers.has(u.userId)); + const userRemoves = [...currentUsers.keys()].filter( + (id) => !targetUsers.has(id), + ); + const roleChanges = [...targetUsers.entries()].filter( + ([id, role]) => currentUsers.has(id) && currentUsers.get(id) !== role, + ); + const groupAdds = groupIds.filter((id) => !currentGroups.has(id)); + const groupRemoves = [...currentGroups].filter((id) => !targetGroups.has(id)); + + // Early no-op: nothing to write, so no transaction is opened + // (`setOrgGroupMembers` precedent). The route's audit + gateway flush still + // run — cheap, and simpler than making withAudit conditional. + if ( + userAdds.length === 0 && + userRemoves.length === 0 && + roleChanges.length === 0 && + groupAdds.length === 0 && + groupRemoves.length === 0 + ) { + return { added: 0, removed: 0, roleChanged: 0 }; + } + + const toOwner = roleChanges + .filter(([, role]) => role === "owner") + .map(([id]) => id); + const toMember = roleChanges + .filter(([, role]) => role === "member") + .map(([id]) => id); + + await db.$transaction([ + // Deletes BEFORE creates: `@@unique([projectId, userId])` and + // `@@unique([projectId, groupId])` are partial (Postgres treats NULLs as + // distinct), so a create racing a delete on the same key would P2002. + db.projectAccess.deleteMany({ + where: { projectId, userId: { in: userRemoves } }, + }), + db.projectAccess.deleteMany({ + where: { projectId, groupId: { in: groupRemoves } }, + }), + db.projectAccess.updateMany({ + where: { projectId, userId: { in: toOwner } }, + data: { role: "owner" }, + }), + db.projectAccess.updateMany({ + where: { projectId, userId: { in: toMember } }, + data: { role: "member" }, + }), + // The DB CHECK is `num_nonnulls(user_id, group_id) = 1`. Each row below is + // built literally, with EXACTLY ONE principal column — never from a shared + // spread object that could carry both. `skipDuplicates` makes a concurrent + // double-add idempotent instead of a P2002. + db.projectAccess.createMany({ + data: userAdds.map((u) => ({ + projectId, + userId: u.userId, + role: u.role, + createdByUserId: actorUserId, + })), + skipDuplicates: true, + }), + db.projectAccess.createMany({ + // Group bindings are ALWAYS "member": the client payload has no group + // role and the gateway ignores `role` entirely — this must never gain a + // silent management path. + data: groupAdds.map((groupId) => ({ + projectId, + groupId, + role: "member", + createdByUserId: actorUserId, + })), + skipDuplicates: true, + }), + ]); + + return { + added: userAdds.length + groupAdds.length, + removed: userRemoves.length + groupRemoves.length, + roleChanged: roleChanges.length, + }; +}; diff --git a/packages/api/src/services/project-service.test.ts b/packages/api/src/services/project-service.test.ts new file mode 100644 index 00000000..d6406731 --- /dev/null +++ b/packages/api/src/services/project-service.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// `canManageProject` in isolation, for the one case the route suite cannot +// reach: an edition with NO RoleResolver registered. `initRoleResolver` is a +// module singleton set by `createApiApp`, so it can only be observed unset in +// a file that mocks the providers module outright. +// +// The invariant: MANAGEMENT must fail closed. Unlike `canAccessProjectAsUser` +// (a USAGE check, which no-ops to `true` for editions without roles), a +// management check that allowed everyone when roles are unavailable would let +// any member rename or delete any project. + +const store = vi.hoisted(() => ({ + /** An `owner` binding that exists — and must still not be consulted. */ + ownerBinding: true, + resolverRole: null as string | null, +})); + +vi.mock("@onecli/db", () => ({ + db: { + projectAccess: { + findFirst: async () => (store.ownerBinding ? { id: "pa-1" } : null), + }, + }, +})); + +vi.mock("../providers", () => ({ + ROLE_HIERARCHY: { member: 1, admin: 2, owner: 3 }, + // No resolver registered — the edition never called `initRoleResolver`. + getRoleResolver: () => + store.resolverRole === null + ? null + : { getUserRole: async () => store.resolverRole }, + getNewOrgPolicySeeder: () => ({ seed: async () => {} }), +})); + +import { canManageProject } from "./project-service"; + +beforeEach(() => { + store.ownerBinding = true; + store.resolverRole = null; +}); + +describe("canManageProject without a RoleResolver", () => { + it("denies, even when an owner binding exists", async () => { + await expect(canManageProject("u-1", "org-1", "proj-1")).resolves.toBe( + false, + ); + }); +}); + +describe("canManageProject with a resolver", () => { + it("denies a user the resolver reports no role for (suspended / non-member)", async () => { + store.resolverRole = null; + await expect(canManageProject("u-1", "org-1", "proj-1")).resolves.toBe( + false, + ); + }); + + it("allows an org admin with no binding at all", async () => { + store.resolverRole = "admin"; + store.ownerBinding = false; + await expect(canManageProject("u-1", "org-1", "proj-1")).resolves.toBe( + true, + ); + }); + + it("allows a plain member holding an owner binding, and denies them without one", async () => { + store.resolverRole = "member"; + await expect(canManageProject("u-1", "org-1", "proj-1")).resolves.toBe( + true, + ); + store.ownerBinding = false; + await expect(canManageProject("u-1", "org-1", "proj-1")).resolves.toBe( + false, + ); + }); +}); diff --git a/packages/api/src/services/project-service.ts b/packages/api/src/services/project-service.ts new file mode 100644 index 00000000..71f90861 --- /dev/null +++ b/packages/api/src/services/project-service.ts @@ -0,0 +1,387 @@ +import { db } from "@onecli/db"; +import { ServiceError } from "./errors"; +import { getRoleResolver, ROLE_HIERARCHY } from "../providers"; +import { + activeMembershipWhere, + hasResolvableProjectExcluding, +} from "./organization-service"; +import { invalidateGatewayCacheForKeys } from "../lib/gateway-invalidate"; + +// Project administration: read, rename, delete. Three rules, same as +// `org-group-service.ts`: +// 1. every resolve is `findFirst({ id, organizationId })`, NEVER +// `findUnique({ where: { id } })` — a cross-org id must read as absent +// (404), not leak another org's row; +// 2. the organization id ALWAYS comes from `auth.organizationId`, never from +// a body or query parameter; +// 3. writes are conditional `updateMany`/`deleteMany` so a lost race is a +// 404, not the P2025 500 a bare `update()`/`delete()` would surface. + +/** A project row in the client's `Project` shape (`createdAt` as ISO string). */ +export interface ProjectRow { + id: string; + name: string | null; + slug: string | null; + createdAt: string; +} + +/** What a delete actually removed. */ +export interface ProjectDeleteResult { + id: string; + name: string | null; + removed: { + agents: number; + apiKeys: number; + secrets: number; + policyRules: number; + policyRulesV2: number; + appConnections: number; + appConfigs: number; + vaultConnections: number; + budgets: number; + accessBindings: number; + onboardingSurvey: number; + }; +} + +const projectSelect = { + id: true, + name: true, + slug: true, + createdAt: true, + createdByUserId: true, +} as const; + +const toProjectRow = (row: { + id: string; + name: string | null; + slug: string | null; + createdAt: Date; +}): ProjectRow => ({ + id: row.id, + name: row.name, + slug: row.slug, + createdAt: row.createdAt.toISOString(), +}); + +/** + * Resolve a project WITHIN the caller's org. A cross-org (or unknown) id reads + * as absent — 404, never 403: a forbidden response would turn the route into an + * existence oracle for another organization's project ids. + */ +export const requireProject = async ( + organizationId: string, + projectId: string, +) => { + const project = await db.project.findFirst({ + where: { id: projectId, organizationId }, + select: projectSelect, + }); + if (!project) throw new ServiceError("NOT_FOUND", "Project not found."); + return project; +}; + +export interface ProjectAuthority { + /** Org admin/owner — Guard H's exemption in `setProjectAccess`. */ + isOrgAdmin: boolean; + canManage: boolean; +} + +/** + * MANAGEMENT authority over a project (step 13c): an org admin/owner, or the + * holder of a USER binding with `role: "owner"`. GROUP bindings never confer + * management in v1 — the gateway and the usage gate both ignore `role`, so a + * group grant is a USE grant only. + * + * Resolves the org role ONCE and derives both signals from it, so a route never + * pays for (or risks disagreeing across) two resolver calls. + * + * Two invariants, both deliberate: + * + * - The role is resolved FIRST and a null role denies (the suspension + * invariant, copied from `canAccessProjectAsUser`): the binding check lives + * INSIDE the active-member gate, so a suspended user's stale owner binding + * can never rescue them. + * - Unlike `canAccessProjectAsUser`, this is NOT gated on `CAPS.rbac`. A usage + * check must no-op (allow) for editions without roles; a MANAGEMENT check + * that allowed everyone there would let any member delete any project. With + * no resolver registered the role reads null and we deny — fail closed. + */ +const resolveAuthority = async ( + userId: string, + organizationId: string, + projectId: string, +): Promise => { + const resolver = getRoleResolver(); + const role = resolver + ? await resolver.getUserRole(userId, organizationId) + : null; + if (!role) return { isOrgAdmin: false, canManage: false }; + if (ROLE_HIERARCHY[role] >= ROLE_HIERARCHY.admin) { + return { isOrgAdmin: true, canManage: true }; + } + + const owner = await db.projectAccess.findFirst({ + where: { projectId, userId, role: "owner" }, + select: { id: true }, + }); + return { isOrgAdmin: false, canManage: owner !== null }; +}; + +export const canManageProject = async ( + userId: string, + organizationId: string, + projectId: string, +): Promise => + (await resolveAuthority(userId, organizationId, projectId)).canManage; + +/** Route helper: resolve (404) THEN authorize (403), never the other way — + * a cross-org project id must never distinguish "exists but forbidden". */ +export const requireManageableProject = async ( + organizationId: string, + userId: string, + projectId: string, +) => { + const project = await requireProject(organizationId, projectId); + const authority = await resolveAuthority(userId, organizationId, projectId); + if (!authority.canManage) { + throw new ServiceError( + "FORBIDDEN", + "You do not have permission to manage this project.", + ); + } + return { project, isOrgAdmin: authority.isOrgAdmin }; +}; + +export const getProject = async ( + organizationId: string, + projectId: string, +): Promise => + toProjectRow(await requireProject(organizationId, projectId)); + +/** + * Rename. `name` ONLY — `slug` is immutable (it is write-only provenance, + * never read by api/web/gateway, and it is `@@unique([organizationId, slug])`, + * so rewriting it could collide). Names are NOT unique per org (see + * `projectNameSchema`), so a rename-to-self and a rename onto a sibling's name + * are both permitted 200s. + */ +export const renameProject = async ( + organizationId: string, + projectId: string, + name: string, +): Promise => { + await requireProject(organizationId, projectId); + + // Org-scoped conditional write: count 0 means the row vanished (or never + // belonged to this org) between the read and the write — 404, not a 500. + const { count } = await db.project.updateMany({ + where: { id: projectId, organizationId }, + data: { name }, + }); + if (count === 0) throw new ServiceError("NOT_FOUND", "Project not found."); + + const row = await db.project.findFirst({ + where: { id: projectId, organizationId }, + select: projectSelect, + }); + if (!row) throw new ServiceError("NOT_FOUND", "Project not found."); + return toProjectRow(row); +}; + +/** + * Delete a project, with an explicit pinned cascade. + * + * A bare `db.project.delete()` is NOT viable: `agents`, `vault_connections` and + * `onboarding_surveys` are `ON DELETE RESTRICT` (and every project is born with + * a default agent, so the P2003 would be universal), while `api_keys`, + * `secrets`, `policy_rules`, `app_connections`, `app_configs` and `budgets` are + * `ON DELETE SET NULL` — they would SURVIVE the project as orphaned + * `scope: "project"` rows with `project_id = NULL`. Both hazards are handled by + * deleting the children explicitly, in FK order, inside ONE transaction. + * + * Three refusals guard the lockout cases (a user with no resolvable project + * gets a 401 on every request — a bricked dashboard, not a degraded one). + * Refusing outright ("empty the project first") is not an option: the default + * agent + API key mean a project can never be emptied through the product. + */ +export const deleteProject = async ( + organizationId: string, + actorUserId: string, + projectId: string, +): Promise => { + const project = await requireProject(organizationId, projectId); + + // ── Guard 1: the org's last project ────────────────────────────────────── + // Deleting it makes EVERY session in the org unresolvable — a total instance + // lockout in OSS, where there is no project switcher to recover through. + const projectCount = await db.project.count({ where: { organizationId } }); + if (projectCount <= 1) { + throw new ServiceError( + "CONFLICT", + "An organization must keep at least one project.", + ); + } + + // ── Guards 2 & 3: stranded users ───────────────────────────────────────── + // Candidates are every human who could be relying on this project: direct + // user bindings ∪ members of groups bound to it ∪ the creator. Restricted to + // ACTIVE members of the org — a suspended or foreign user cannot be stranded + // by definition (they resolve no project either way). + const [userBindings, groupBindings] = await Promise.all([ + db.projectAccess.findMany({ + where: { projectId, userId: { not: null } }, + select: { userId: true }, + }), + db.projectAccess.findMany({ + where: { projectId, groupId: { not: null } }, + select: { group: { select: { members: { select: { userId: true } } } } }, + }), + ]); + + const candidates = new Set(); + for (const row of userBindings) if (row.userId) candidates.add(row.userId); + for (const row of groupBindings) { + for (const m of row.group?.members ?? []) candidates.add(m.userId); + } + if (project.createdByUserId) candidates.add(project.createdByUserId); + + const activeMembers = await db.organizationMember.findMany({ + where: { + organizationId, + userId: { in: [...candidates] }, + // The shared "active member" filter, so a future change to what counts + // as active lands here too instead of silently narrowing this guard. + ...activeMembershipWhere, + }, + select: { userId: true }, + }); + const atRisk = new Set(activeMembers.map((row) => row.userId)); + + // Guard 3 (self) is checked FIRST so the actor's own case yields the sharper + // message rather than being folded into the anonymous count below. + if ( + atRisk.has(actorUserId) && + !(await hasResolvableProjectExcluding(actorUserId, projectId)) + ) { + throw new ServiceError( + "CONFLICT", + "Deleting this project would leave you with no project.", + ); + } + + // Guard 2: a serial loop, deliberately. The candidate set is bounded by the + // access-PUT caps and this is a rare destructive action — per-user + // correctness matters more than collapsing it into one clever query. + let stranded = 0; + for (const userId of atRisk) { + if (userId === actorUserId) continue; // handled above + if (!(await hasResolvableProjectExcluding(userId, projectId))) stranded++; + } + if (stranded > 0) { + throw new ServiceError( + "CONFLICT", + `Deleting this project would leave ${stranded} member(s) with no project. Give them access to another project first.`, + ); + } + + // Flush the gateway BEFORE the cascade, never after: `/v1/cache/invalidate` + // authenticates the bearer through an UNCACHED `find_api_key` lookup + // (apps/gateway/src/auth.rs), so a key deleted a moment ago cannot + // authenticate its own flush — a post-delete call would silently 401 and + // flush nothing. Flushing here is safe in both directions: if the + // transaction below rolls back the gateway simply re-reads the config it + // just dropped. + const keyRows = await db.apiKey.findMany({ + where: { projectId }, + select: { key: true }, + }); + invalidateGatewayCacheForKeys(keyRows.map((row) => row.key)); + + const [ + agents, + apiKeys, + secrets, + policyRules, + policyRulesV2, + appConnections, + appConfigs, + vaultConnections, + budgets, + accessBindings, + onboardingSurvey, + ] = await Promise.all([ + db.agent.count({ where: { projectId } }), + db.apiKey.count({ where: { projectId } }), + db.secret.count({ where: { projectId } }), + db.policyRule.count({ where: { projectId } }), + db.policyRuleV2.count({ where: { projectId } }), + db.appConnection.count({ where: { projectId } }), + db.appConfig.count({ where: { projectId } }), + db.vaultConnection.count({ where: { projectId } }), + db.budget.count({ where: { projectId } }), + db.projectAccess.count({ where: { projectId } }), + db.onboardingSurvey.count({ where: { projectId } }), + ]); + + // One transaction, children first, in FK order. Each line carries its FK + // action so a future schema change is caught in review: a new RESTRICT child + // without a line here is a P2003, a new SET NULL child is a silent orphan. + // + // Interactive (callback) form, not the array form, precisely so the final + // `count === 0` check below can ROLL THE CASCADE BACK by throwing. + await db.$transaction(async (tx) => { + // RESTRICT — must precede the project. Cascades agent_secrets, + // agent_app_connections, grant_rules, policy_rule_identities(agent). + await tx.agent.deleteMany({ where: { projectId } }); + // SET NULL — explicit, else orphaned scope:"project" rows survive. + // Cascades secret_access, budgets, policy_rule_targets(secret). + await tx.secret.deleteMany({ where: { projectId } }); + // SET NULL — cascades connection_access, policy_rule_targets(connection). + await tx.appConnection.deleteMany({ where: { projectId } }); + // SET NULL + await tx.appConfig.deleteMany({ where: { projectId } }); + // SET NULL — an orphaned PROJECT api key must never outlive its project. + await tx.apiKey.deleteMany({ where: { projectId } }); + // SET NULL (legacy rule model) + await tx.policyRule.deleteMany({ where: { projectId } }); + // SET NULL (cloud-only budgets; inert in OSS) + await tx.budget.deleteMany({ where: { projectId } }); + // RESTRICT + await tx.vaultConnection.deleteMany({ where: { projectId } }); + // RESTRICT + await tx.onboardingSurvey.deleteMany({ where: { projectId } }); + + // Org-scoped conditional delete. Deliberately NOT deleted by hand: + // · policy_rules_v2 + project_access — DB CASCADE, removed with the row; + // · audit_logs — SET NULL by design: history SURVIVES and stays + // attributable through organization_id. Never delete audit rows. + // · request_logs — no FK at all: telemetry keeps a dangling project_id and + // becomes unreachable. Deleting it could be millions of rows in one + // transaction; out of scope here. + const { count } = await tx.project.deleteMany({ + where: { id: projectId, organizationId }, + }); + // A 0 here means the project vanished (or was never ours) between the + // resolve and the write — throwing rolls the whole cascade back. + if (count === 0) throw new ServiceError("NOT_FOUND", "Project not found."); + }); + + return { + id: project.id, + name: project.name, + removed: { + agents, + apiKeys, + secrets, + policyRules, + policyRulesV2, + appConnections, + appConfigs, + vaultConnections, + budgets, + accessBindings, + onboardingSurvey, + }, + }; +}; diff --git a/packages/api/src/validations/org.ts b/packages/api/src/validations/org.ts index 1e42d62e..260c6359 100644 --- a/packages/api/src/validations/org.ts +++ b/packages/api/src/validations/org.ts @@ -62,6 +62,120 @@ export const createInvitationSchema = z.object({ export type CreateInvitationInput = z.infer; +// ── Groups ──────────────────────────────────────────────────────────────── + +/** + * Group provenance — a READ-side filter only. `source` is never accepted on a + * write: creates hard-code `"manual"`, and `"scim"` rows (IdP-provisioned in + * EE) reject every mutation with 409 so the dashboard can never fight the + * IdP over ownership of a provisioned group. + */ +export const groupSourceSchema = z.enum(["manual", "scim"]); + +/** Group display name — trimmed, 1–100 chars. */ +export const groupNameSchema = z.string().trim().min(1).max(100); + +/** + * Replace-set ceiling for a single group's membership. Deliberately NOT + * `DIRECTORY_LIMIT_MAX` (a page-size bound, 200): the members dialog drains + * every page and PUTs the full set back, so the write cap must comfortably + * exceed one page while still bounding the request body. + */ +export const MAX_GROUP_MEMBERS = 1000; + +export const groupListQuerySchema = directoryListQuerySchema.extend({ + source: groupSourceSchema.optional(), +}); + +export type GroupListQuery = z.infer; + +/** Body `source`/`externalId` are ignored by construction: not in the schema. */ +export const createGroupSchema = z.object({ name: groupNameSchema }); + +export const renameGroupSchema = z.object({ name: groupNameSchema }); + +export const setGroupMembersSchema = z.object({ + userIds: z.array(z.string().min(1)).max(MAX_GROUP_MEMBERS), +}); + +// ── Role mappings ──────────────────────────────────────────────────────── + +/** + * Ceiling on the org's mapping set — enforced on BOTH ends, and it has to be: + * `PUT /order` takes the FULL ordered id set, so an org that can hold more + * mappings than this body allows would have an unreachable reorder endpoint + * (every honest body 422s on `.max()`, every shorter one 409s on the + * names-every-mapping-once check) and no way to resolve shadowing. The chosen + * invariant is therefore `mappings ≤ MAX_ROLE_MAPPINGS`: `createOrgRoleMapping` + * 409s at the cap, so the reorder body can always name the whole set. + */ +export const MAX_ROLE_MAPPINGS = 500; + +/** + * Mappings assign exactly the roles the members surface can — never `owner`. + * Owner is bootstrap-only, and `updateOrgMemberRole` already refuses to assign + * or overwrite it, so a body carrying `role: "owner"` is a 422 here rather + * than a privilege the mapping engine could mint. + */ +export const roleMappingRoleSchema = orgMemberRoleSchema; + +/** + * `priority` is an ASCENDING rank: 0 = highest precedence (evaluated first / + * wins). Omitted on create means "append after the current last mapping". + * + * NOT coerced, unlike `limit`: this field only ever arrives in a JSON body, so + * it is already a number when it is one. Coercion would turn `null`, `""`, + * `false` and `[]` into `0` — the HIGHEST-precedence slot, the single most + * consequential value the field can take — instead of the 422 a body that + * meant "no priority" deserves. `.optional()` still short-circuits `undefined`, + * so the omitted-priority append path is unaffected. + */ +export const roleMappingPrioritySchema = z.number().int().min(0).max(100_000); + +export const createRoleMappingSchema = z.object({ + groupId: z.string().min(1), + role: roleMappingRoleSchema, + priority: roleMappingPrioritySchema.optional(), +}); + +export type CreateRoleMappingInput = z.infer; + +export const updateRoleMappingSchema = z.object({ + role: roleMappingRoleSchema, + priority: roleMappingPrioritySchema.optional(), +}); + +export type UpdateRoleMappingInput = z.infer; + +/** + * The FULL ordered id set, index 0 = highest priority. A body that does not + * name every current mapping exactly once is STALE → 409 in the service (the + * `reorderPolicyRules` precedent); duplicates are malformed → 422 here. + * + * Deliberately NOT `.min(1)` (unlike `reorderPolicyRulesSchema`): an org with + * zero mappings legitimately reorders to `[]`, and a minimum would 422 that + * honest no-op. + */ +export const reorderRoleMappingsSchema = z.object({ + orderedIds: z + .array(z.string().min(1)) + .max(MAX_ROLE_MAPPINGS) + .refine((ids) => new Set(ids).size === ids.length, { + message: "orderedIds must not contain duplicates.", + }), +}); + +/** + * Dry run. No `priority`: an existing mapping is previewed at its current + * slot, a new one as if appended last — exactly what the create button does. + */ +export const previewRoleMappingSchema = z.object({ + groupId: z.string().min(1), + role: roleMappingRoleSchema, +}); + +export type PreviewRoleMappingInput = z.infer; + /** * `PATCH /v1/org/members/:userId` accepts EXACTLY ONE change per request — * either a lifecycle change (`status`) or a role change (`role`). A body diff --git a/packages/api/src/validations/project.ts b/packages/api/src/validations/project.ts new file mode 100644 index 00000000..d3eceb53 --- /dev/null +++ b/packages/api/src/validations/project.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; + +// Validation for the `/v1/projects/*` surface (rename + the access replace-set). +// Mirrors `validations/org.ts` in shape; kept separate because projects are a +// project-scoped resource, not part of the org directory. + +/** + * Project display name. Bounded like a group name, but deliberately NOT unique + * per organization: `ensureMemberDefaultProject` names EVERY invited member's + * project "Default", so a uniqueness rule would 409 the most common state in + * the product. + */ +export const projectNameSchema = z.string().trim().min(1).max(100); + +export const renameProjectSchema = z.object({ name: projectNameSchema }); + +/** + * The management role on a USER binding (step 13c): "owner" may + * rename/share/delete the project, "member" is a plain use grant. GROUP + * bindings carry no role in v1 — they are always written as "member". + */ +export const projectAccessRoleSchema = z.enum(["owner", "member"]); + +/** + * Replace-set ceilings. Deliberately not `DIRECTORY_LIMIT_MAX` (a page size): + * the sharing dialog drains every page of the org directory and PUTs the full + * set back, so the write cap must comfortably exceed one page while still + * bounding the request body. + */ +export const MAX_PROJECT_ACCESS_USERS = 1000; +export const MAX_PROJECT_ACCESS_GROUPS = 200; + +/** + * `PUT /v1/projects/:projectId/access` body. Both keys are REQUIRED (no + * `.default([])`): a client bug that omits one must be a 422, never a silent + * half-wipe of the project's bindings. + */ +export const setProjectAccessSchema = z + .object({ + users: z + .array( + z.object({ + userId: z.string().min(1), + role: projectAccessRoleSchema, + }), + ) + .max(MAX_PROJECT_ACCESS_USERS), + groupIds: z.array(z.string().min(1)).max(MAX_PROJECT_ACCESS_GROUPS), + }) + .superRefine((body, ctx) => { + // A duplicate userId is REJECTED rather than resolved "last wins": each + // entry carries a role, so a repeat with a conflicting role is genuinely + // ambiguous. `groupIds` carry no role and are deduped silently in the + // service (the setOrgGroupMembers precedent). + const seen = new Set(); + for (const user of body.users) { + if (seen.has(user.userId)) { + ctx.addIssue({ + code: "custom", + message: "Duplicate user in the access set.", + }); + return; + } + seen.add(user.userId); + } + }); + +export type SetProjectAccessInput = z.infer;